You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@brooklyn.apache.org by dr...@apache.org on 2017/07/14 13:53:20 UTC

[1/3] brooklyn-server git commit: Adds AsyncStartable marker interface

Repository: brooklyn-server
Updated Branches:
  refs/heads/master ee5514951 -> c10fd58eb


Adds AsyncStartable marker interface


Project: http://git-wip-us.apache.org/repos/asf/brooklyn-server/repo
Commit: http://git-wip-us.apache.org/repos/asf/brooklyn-server/commit/b7247784
Tree: http://git-wip-us.apache.org/repos/asf/brooklyn-server/tree/b7247784
Diff: http://git-wip-us.apache.org/repos/asf/brooklyn-server/diff/b7247784

Branch: refs/heads/master
Commit: b7247784dc3854bf873d41d41bb4a730e65dfc0d
Parents: 8643806
Author: Aled Sage <al...@gmail.com>
Authored: Tue Jul 4 07:07:05 2017 +0100
Committer: Aled Sage <al...@gmail.com>
Committed: Thu Jul 6 16:08:37 2017 +0100

----------------------------------------------------------------------
 .../core/entity/trait/AsyncStartable.java       | 34 ++++++++++
 .../mgmt/rebind/BasicEntityRebindSupport.java   |  5 +-
 ...ftwareProcessRebindNotRunningEntityTest.java | 69 ++++++++++++++++++++
 3 files changed, 107 insertions(+), 1 deletion(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/brooklyn-server/blob/b7247784/core/src/main/java/org/apache/brooklyn/core/entity/trait/AsyncStartable.java
----------------------------------------------------------------------
diff --git a/core/src/main/java/org/apache/brooklyn/core/entity/trait/AsyncStartable.java b/core/src/main/java/org/apache/brooklyn/core/entity/trait/AsyncStartable.java
new file mode 100644
index 0000000..4adb195
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/core/entity/trait/AsyncStartable.java
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+package org.apache.brooklyn.core.entity.trait;
+
+import com.google.common.annotations.Beta;
+
+/**
+ * Marker interface, indicating that this starts up asynchronously.
+ * 
+ * That is, calling {@link Startable#start(java.util.Collection)} can return while the entity is 
+ * still in the state {@link org.apache.brooklyn.core.entity.lifecycle.Lifecycle#STARTING}. It is 
+ * expected that the entity will subsequently transition to 
+ * {@link org.apache.brooklyn.core.entity.lifecycle.Lifecycle#RUNNING} 
+ * (and {@link org.apache.brooklyn.core.entity.Attributes#SERVICE} {@code true}).
+ */
+@Beta
+public interface AsyncStartable extends Startable {
+}

http://git-wip-us.apache.org/repos/asf/brooklyn-server/blob/b7247784/core/src/main/java/org/apache/brooklyn/core/mgmt/rebind/BasicEntityRebindSupport.java
----------------------------------------------------------------------
diff --git a/core/src/main/java/org/apache/brooklyn/core/mgmt/rebind/BasicEntityRebindSupport.java b/core/src/main/java/org/apache/brooklyn/core/mgmt/rebind/BasicEntityRebindSupport.java
index aa3e7a8..5fab5f9 100644
--- a/core/src/main/java/org/apache/brooklyn/core/mgmt/rebind/BasicEntityRebindSupport.java
+++ b/core/src/main/java/org/apache/brooklyn/core/mgmt/rebind/BasicEntityRebindSupport.java
@@ -38,6 +38,7 @@ import org.apache.brooklyn.core.entity.internal.AttributesInternal;
 import org.apache.brooklyn.core.entity.internal.AttributesInternal.ProvisioningTaskState;
 import org.apache.brooklyn.core.entity.lifecycle.Lifecycle;
 import org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic;
+import org.apache.brooklyn.core.entity.trait.AsyncStartable;
 import org.apache.brooklyn.core.feed.AbstractFeed;
 import org.apache.brooklyn.core.location.Machines;
 import org.apache.brooklyn.core.objs.AbstractBrooklynObject;
@@ -232,7 +233,9 @@ public class BasicEntityRebindSupport extends AbstractBrooklynObjectRebindSuppor
     protected void instanceRebind(AbstractBrooklynObject instance) {
         Preconditions.checkState(instance == entity, "Expected %s and %s to match, but different objects", instance, entity);
         Lifecycle expectedState = ServiceStateLogic.getExpectedState(entity);
-        if (expectedState == Lifecycle.STARTING || expectedState == Lifecycle.STOPPING) {
+        boolean isAsync = (entity instanceof AsyncStartable);
+        
+        if ((!isAsync && expectedState == Lifecycle.STARTING) || expectedState == Lifecycle.STOPPING) {
             // If we were previously "starting" or "stopping", then those tasks will have been 
             // aborted. We don't want to continue showing that state (e.g. the web-console would
             // then show the it as in-progress with the "spinning" icon).

http://git-wip-us.apache.org/repos/asf/brooklyn-server/blob/b7247784/software/base/src/test/java/org/apache/brooklyn/entity/software/base/SoftwareProcessRebindNotRunningEntityTest.java
----------------------------------------------------------------------
diff --git a/software/base/src/test/java/org/apache/brooklyn/entity/software/base/SoftwareProcessRebindNotRunningEntityTest.java b/software/base/src/test/java/org/apache/brooklyn/entity/software/base/SoftwareProcessRebindNotRunningEntityTest.java
index 9c9987c..be591ef 100644
--- a/software/base/src/test/java/org/apache/brooklyn/entity/software/base/SoftwareProcessRebindNotRunningEntityTest.java
+++ b/software/base/src/test/java/org/apache/brooklyn/entity/software/base/SoftwareProcessRebindNotRunningEntityTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.brooklyn.entity.software.base;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertTrue;
@@ -33,6 +34,7 @@ import java.util.concurrent.TimeUnit;
 
 import org.apache.brooklyn.api.entity.Entity;
 import org.apache.brooklyn.api.entity.EntitySpec;
+import org.apache.brooklyn.api.entity.ImplementedBy;
 import org.apache.brooklyn.api.location.Location;
 import org.apache.brooklyn.api.location.LocationSpec;
 import org.apache.brooklyn.api.location.MachineLocation;
@@ -41,10 +43,13 @@ import org.apache.brooklyn.api.location.NoMachinesAvailableException;
 import org.apache.brooklyn.api.mgmt.ha.HighAvailabilityMode;
 import org.apache.brooklyn.config.ConfigKey;
 import org.apache.brooklyn.core.config.ConfigKeys;
+import org.apache.brooklyn.core.entity.AbstractEntity;
 import org.apache.brooklyn.core.entity.Attributes;
 import org.apache.brooklyn.core.entity.EntityAsserts;
 import org.apache.brooklyn.core.entity.internal.AttributesInternal;
 import org.apache.brooklyn.core.entity.lifecycle.Lifecycle;
+import org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic;
+import org.apache.brooklyn.core.entity.trait.AsyncStartable;
 import org.apache.brooklyn.core.entity.trait.Startable;
 import org.apache.brooklyn.core.location.AbstractLocation;
 import org.apache.brooklyn.core.mgmt.rebind.RebindOptions;
@@ -321,6 +326,30 @@ public class SoftwareProcessRebindNotRunningEntityTest extends RebindTestFixture
         assertNotMarkedOnfire(newApp, Lifecycle.STARTING);
     }
     
+    @Test
+    public void testRebindAsyncStartableWhileStarting() throws Exception {
+        AsyncEntity entity = app().createAndManageChild(EntitySpec.create(AsyncEntity.class));
+        
+        app().start(ImmutableList.of(locationProvisioner));
+
+        EntityAsserts.assertAttributeEqualsEventually(entity, Attributes.SERVICE_STATE_ACTUAL, Lifecycle.STARTING);
+        EntityAsserts.assertAttributeEqualsEventually(app(), Attributes.SERVICE_STATE_ACTUAL, Lifecycle.RUNNING);
+        assertEquals(entity.sensors().get(Attributes.SERVICE_STATE_EXPECTED).getState(), Lifecycle.STARTING);
+
+        TestApplication newApp = rebind();
+        final AsyncEntity newEntity = (AsyncEntity) Iterables.find(newApp.getChildren(), Predicates.instanceOf(AsyncEntity.class));
+
+        assertNotMarkedOnfire(newEntity, Lifecycle.STARTING);
+        assertNotMarkedOnfire(newApp, Lifecycle.RUNNING);
+
+        // Set the async entity to completed; expect it to correctly transition (even after rebind) 
+        newEntity.clearNotUpIndicator();
+        newEntity.setExpected(Lifecycle.RUNNING);
+        
+        EntityAsserts.assertAttributeEqualsEventually(newEntity, Attributes.SERVICE_STATE_ACTUAL, Lifecycle.RUNNING);
+        EntityAsserts.assertAttributeEqualsEventually(newEntity, Attributes.SERVICE_UP, true);
+    }
+
     protected ListenableFuture<Void> startAsync(final Startable entity, final Collection<? extends Location> locs) {
         return executor.submit(new Callable<Void>() {
             @Override public Void call() throws Exception {
@@ -459,4 +488,44 @@ public class SoftwareProcessRebindNotRunningEntityTest extends RebindTestFixture
             }
         }
     }
+    
+    /**
+     * The AsyncEntity's start leaves it in a "STARTING" state.
+     * 
+     * It stays like that until {@code clearNotUpIndicator(); setExpected(Lifecycle.RUNNING)} is 
+     * called. It should then report "RUNNING" and service.isUp=true.
+     */
+    @ImplementedBy(AsyncEntityImpl.class)
+    public interface AsyncEntity extends Entity, AsyncStartable {
+        void setExpected(Lifecycle state);
+        void clearNotUpIndicator();
+    }
+    
+    public static class AsyncEntityImpl extends AbstractEntity implements AsyncEntity {
+
+        @Override
+        public void start(Collection<? extends Location> locations) {
+            ServiceStateLogic.setExpectedState(this, Lifecycle.STARTING);
+            ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, START.getName(), "starting");
+        }
+
+
+        @Override
+        public void setExpected(Lifecycle state) {
+            ServiceStateLogic.setExpectedState(this, checkNotNull(state, "state"));
+        }
+
+        @Override
+        public void clearNotUpIndicator() {
+            ServiceStateLogic.ServiceNotUpLogic.clearNotUpIndicator(this, START.getName());
+        }
+        
+        @Override
+        public void stop() {
+        }
+
+        @Override
+        public void restart() {
+        }
+    }
 }


[2/3] brooklyn-server git commit: Adds AsyncApplication, and tests

Posted by dr...@apache.org.
Adds AsyncApplication, and tests


Project: http://git-wip-us.apache.org/repos/asf/brooklyn-server/repo
Commit: http://git-wip-us.apache.org/repos/asf/brooklyn-server/commit/13c81772
Tree: http://git-wip-us.apache.org/repos/asf/brooklyn-server/tree/13c81772
Diff: http://git-wip-us.apache.org/repos/asf/brooklyn-server/diff/13c81772

Branch: refs/heads/master
Commit: 13c817720e82984f120d2356c7f9722560cd133b
Parents: b724778
Author: Aled Sage <al...@gmail.com>
Authored: Thu Jul 6 16:07:15 2017 +0100
Committer: Aled Sage <al...@gmail.com>
Committed: Fri Jul 14 14:13:14 2017 +0100

----------------------------------------------------------------------
 .../core/entity/AbstractApplication.java        |   2 +-
 .../brooklyn/entity/stock/AsyncApplication.java |  32 ++
 .../entity/stock/AsyncApplicationImpl.java      | 479 +++++++++++++++++++
 .../brooklyn/core/entity/EntityAsyncTest.java   | 361 ++++++++++++++
 .../entity/stock/AsyncApplicationTest.java      | 457 ++++++++++++++++++
 5 files changed, 1330 insertions(+), 1 deletion(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/brooklyn-server/blob/13c81772/core/src/main/java/org/apache/brooklyn/core/entity/AbstractApplication.java
----------------------------------------------------------------------
diff --git a/core/src/main/java/org/apache/brooklyn/core/entity/AbstractApplication.java b/core/src/main/java/org/apache/brooklyn/core/entity/AbstractApplication.java
index 3cbee66..3a36c75 100644
--- a/core/src/main/java/org/apache/brooklyn/core/entity/AbstractApplication.java
+++ b/core/src/main/java/org/apache/brooklyn/core/entity/AbstractApplication.java
@@ -213,7 +213,7 @@ public abstract class AbstractApplication extends AbstractEntity implements Star
         }
     }
 
-    private static class ProblemStartingChildrenException extends RuntimeException {
+    protected static class ProblemStartingChildrenException extends RuntimeException {
         private static final long serialVersionUID = 7710856289284536803L;
         private ProblemStartingChildrenException(Exception cause) { super(cause); }
     }

http://git-wip-us.apache.org/repos/asf/brooklyn-server/blob/13c81772/core/src/main/java/org/apache/brooklyn/entity/stock/AsyncApplication.java
----------------------------------------------------------------------
diff --git a/core/src/main/java/org/apache/brooklyn/entity/stock/AsyncApplication.java b/core/src/main/java/org/apache/brooklyn/entity/stock/AsyncApplication.java
new file mode 100644
index 0000000..16ffaa4
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/entity/stock/AsyncApplication.java
@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+package org.apache.brooklyn.entity.stock;
+
+import org.apache.brooklyn.api.entity.ImplementedBy;
+import org.apache.brooklyn.core.entity.StartableApplication;
+import org.apache.brooklyn.core.entity.trait.AsyncStartable;
+
+/**
+ * An app that starts up asynchronously. Calling start will call start on its children,
+ * but it does not expect the children to have started by the time the start() effector
+ * has returned. Instead, it infers from the children's state whether they are up or not.
+ */
+@ImplementedBy(AsyncApplicationImpl.class)
+public interface AsyncApplication extends StartableApplication, AsyncStartable {
+}

http://git-wip-us.apache.org/repos/asf/brooklyn-server/blob/13c81772/core/src/main/java/org/apache/brooklyn/entity/stock/AsyncApplicationImpl.java
----------------------------------------------------------------------
diff --git a/core/src/main/java/org/apache/brooklyn/entity/stock/AsyncApplicationImpl.java b/core/src/main/java/org/apache/brooklyn/entity/stock/AsyncApplicationImpl.java
new file mode 100644
index 0000000..636ab4f
--- /dev/null
+++ b/core/src/main/java/org/apache/brooklyn/entity/stock/AsyncApplicationImpl.java
@@ -0,0 +1,479 @@
+/*
+ * 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.
+ */
+package org.apache.brooklyn.entity.stock;
+
+import static org.apache.brooklyn.core.entity.Attributes.SERVICE_STATE_ACTUAL;
+import static org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic.clearMapSensorEntry;
+import static org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic.updateMapSensorEntry;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.brooklyn.api.entity.Entity;
+import org.apache.brooklyn.api.entity.EntityLocal;
+import org.apache.brooklyn.api.location.Location;
+import org.apache.brooklyn.api.sensor.EnricherSpec;
+import org.apache.brooklyn.api.sensor.Sensor;
+import org.apache.brooklyn.api.sensor.SensorEvent;
+import org.apache.brooklyn.api.sensor.SensorEventListener;
+import org.apache.brooklyn.config.ConfigKey;
+import org.apache.brooklyn.core.BrooklynLogging;
+import org.apache.brooklyn.core.config.BasicConfigInheritance;
+import org.apache.brooklyn.core.config.ConfigKeys;
+import org.apache.brooklyn.core.entity.AbstractApplication;
+import org.apache.brooklyn.core.entity.Attributes;
+import org.apache.brooklyn.core.entity.Entities;
+import org.apache.brooklyn.core.entity.lifecycle.Lifecycle;
+import org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic;
+import org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic.ServiceProblemsLogic;
+import org.apache.brooklyn.enricher.stock.AbstractMultipleSensorAggregator;
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.collections.MutableSet;
+import org.apache.brooklyn.util.collections.QuorumCheck;
+import org.apache.brooklyn.util.exceptions.Exceptions;
+import org.apache.brooklyn.util.text.Strings;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.reflect.TypeToken;
+
+public class AsyncApplicationImpl extends AbstractApplication implements AsyncApplication {
+
+    private static final Logger LOG = LoggerFactory.getLogger(AsyncApplicationImpl.class);
+
+    @Override
+    public void init() {
+        // Code below copied from BasicAppliationImpl.
+        // Set the default name *before* calling super.init(), and only do so if we don't have an 
+        // explicit default. This is a belt-and-braces fix: before we overwrote the defaultDisplayName
+        // that was inferred from the catalog item name.
+        if (Strings.isBlank(getConfig(DEFAULT_DISPLAY_NAME))) {
+            setDefaultDisplayName("Application ("+getId()+")");
+        }
+        super.init();
+    }
+    
+    @Override
+    protected void initEnrichers() {
+        // Deliberately not calling `super.initEnrichers()`. For our state (i.e. "service.state" 
+        // and "service.isUp"), we rely on the `serviceStateComputer`. This keeps things a lot
+        // simpler. However, it means that if someone manually sets a "service.notUp.indicators" 
+        // or "service.problems" then that won't cause the entity to transition to false or ON_FIRE.
+        
+        enrichers().add(EnricherSpec.create(ServiceStateComputer.class)
+                .configure(ServiceStateComputer.FROM_CHILDREN, true)
+                .configure(ServiceStateComputer.UP_QUORUM_CHECK, config().get(UP_QUORUM_CHECK))
+                .configure(ServiceStateComputer.RUNNING_QUORUM_CHECK, config().get(RUNNING_QUORUM_CHECK)));
+
+    }
+
+    // Only overrides AbstractApplication.start so as to disable the publishing of expected=running.
+    // Code is copy-pasted verbatim from AbstractAppliation, except for not setting Lifecycle.RUNNING. 
+    @Override
+    public void start(Collection<? extends Location> locations) {
+        this.addLocations(locations);
+        // 2016-01: only pass locations passed to us, as per ML discussion
+        Collection<? extends Location> locationsToUse = locations==null ? ImmutableSet.<Location>of() : locations;
+        ServiceProblemsLogic.clearProblemsIndicator(this, START);
+        ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, Attributes.SERVICE_STATE_ACTUAL, "Application starting");
+        ServiceStateLogic.ServiceNotUpLogic.clearNotUpIndicator(this, START.getName());
+        setExpectedStateAndRecordLifecycleEvent(Lifecycle.STARTING);
+        try {
+            try {
+                
+                preStart(locationsToUse);
+                
+                // Opportunity to block startup until other dependent components are available
+                Object val = config().get(START_LATCH);
+                if (val != null) LOG.debug("{} finished waiting for start-latch; continuing...", this);
+                
+                doStart(locationsToUse);
+                postStart(locationsToUse);
+                
+            } catch (ProblemStartingChildrenException e) {
+                throw Exceptions.propagate(e);
+            } catch (Exception e) {
+                // should remember problems, apart from those that happened starting children
+                // fixed bug introduced by the fix in dacf18b831e1e5e1383d662a873643a3c3cabac6
+                // where failures in special code at application root don't cause app to go on fire 
+                ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, START.getName(), Exceptions.collapseText(e));
+                throw Exceptions.propagate(e);
+            }
+            
+        } catch (Exception e) {
+            recordApplicationEvent(Lifecycle.ON_FIRE);
+            ServiceStateLogic.setExpectedStateRunningWithErrors(this);
+            
+            // no need to log here; the effector invocation should do that
+            throw Exceptions.propagate(e);
+            
+        } finally {
+            ServiceStateLogic.ServiceNotUpLogic.clearNotUpIndicator(this, Attributes.SERVICE_STATE_ACTUAL);
+        }
+        
+        // CHANGE FROM SUPER: NOT CALLING THESE
+        // ServiceStateLogic.setExpectedState(this, Lifecycle.RUNNING);
+        // setExpectedStateAndRecordLifecycleEvent(Lifecycle.RUNNING);
+        //
+        // logApplicationLifecycle("Started");
+    }
+
+    /**
+     * Calculates the "service.state" and "service.isUp", based on the state of the children. It 
+     * also transitions from expected=starting to expected=running once all of the children have
+     * finished their starting.
+     * 
+     * This does <em>not</em> just rely on the "service.problems" and "service.notUp.indicators"
+     * because those make different assumptions about the expected state. Instead it seems much
+     * easier to implement the specific logic for async startup here.
+     * 
+     * The important part of the implementation is {@link #onUpdated()}, and its helper methods
+     * for {@link #computeServiceUp(Lifecycle)}, {@link #computeServiceState(Lifecycle)} and
+     * {@link #computeExpectedState(Lifecycle, Lifecycle)}.
+     * 
+     * This class is not to be instantiated directly. Instead, if cusotmization is desired then override 
+     * {@link AsyncApplicationImpl#initEnrichers()} to create and add this enricher (with the same unique 
+     * tag, to replace the default).
+     */
+    public static class ServiceStateComputer extends AbstractMultipleSensorAggregator<Void> implements SensorEventListener<Object> {
+        /** standard unique tag identifying instances of this enricher at runtime */
+        public final static String DEFAULT_UNIQUE_TAG = "async-service-state-computer";
+
+        public static final ConfigKey<QuorumCheck> RUNNING_QUORUM_CHECK = ConfigKeys.builder(QuorumCheck.class, "runningQuorumCheck")
+                .description("Logic for checking whether this service is running, based on children and/or "
+                        + "members running (by default requires all, but ignores any that are stopping)")
+                .defaultValue(QuorumCheck.QuorumChecks.all())
+                .runtimeInheritance(BasicConfigInheritance.NOT_REINHERITED)
+                .build();
+
+        public static final ConfigKey<QuorumCheck> UP_QUORUM_CHECK = ConfigKeys.builder(QuorumCheck.class, "upQuorumCheck")
+              .description("Logic for checking whether this service is up, based on children and/or members (by default requires all)")
+              .defaultValue(QuorumCheck.QuorumChecks.all())
+              .runtimeInheritance(BasicConfigInheritance.NOT_REINHERITED)
+              .build();
+        
+        // TODO How does this relate to quorum?!
+        @SuppressWarnings("serial")
+        public static final ConfigKey<Set<Lifecycle>> ENTITY_FAILED_STATES = ConfigKeys.builder(new TypeToken<Set<Lifecycle>>() {})
+                .name("entityFailedStates")
+                .description("Service states that indicate a child/member has failed (by default just ON_FIRE will mean not healthy)")
+                .defaultValue(ImmutableSet.of(Lifecycle.ON_FIRE))
+                .runtimeInheritance(BasicConfigInheritance.NOT_REINHERITED)
+                .build();
+
+        @SuppressWarnings("serial")
+        public static final ConfigKey<Set<Lifecycle>> ENTITY_TRANSITION_STATES_ON_STARTING = ConfigKeys.builder(new TypeToken<Set<Lifecycle>>() {})
+                .name("entityTransitionStatesOnStarting")
+                .description("Service states which indicate a child/member is still starting "
+                        + "(used to compute when we have finished starting)")
+                .defaultValue(MutableSet.of(null, Lifecycle.CREATED, Lifecycle.STARTING).asUnmodifiable())
+                .build();
+
+        @SuppressWarnings("serial")
+        public static final ConfigKey<Set<Lifecycle>> ENTITY_IGNORED_STATES_ON_STARTING = ConfigKeys.builder(new TypeToken<Set<Lifecycle>>() {})
+                .name("entityIgnoredStatesOnStarting")
+                .description("Service states of a child/member that mean we'll ignore it, for calculating "
+                        + "our own state when 'staring' (by default ignores children that are stopping/stopped)")
+                .defaultValue(ImmutableSet.of(Lifecycle.STOPPING, Lifecycle.STOPPED, Lifecycle.DESTROYED))
+                .build();
+
+        @SuppressWarnings("serial")
+        public static final ConfigKey<Set<Lifecycle>> ENTITY_IGNORED_STATES_ON_OTHERS = ConfigKeys.builder(new TypeToken<Set<Lifecycle>>() {})
+                .name("entityIgnoredStatesOnOthers")
+                .description("Service states of a child/member that mean we'll ignore it, for calculating "
+                        + "our own state when we are not 'staring' (by default ignores children that are starting/stopping)")
+                .defaultValue(MutableSet.of(null, Lifecycle.STOPPING, Lifecycle.STOPPED, Lifecycle.DESTROYED, Lifecycle.CREATED, Lifecycle.STARTING).asUnmodifiable())
+                .build();
+
+        public static final ConfigKey<Boolean> IGNORE_ENTITIES_WITH_SERVICE_UP_NULL = ConfigKeys.builder(Boolean.class)
+                .name("ignoreEntitiesWithServiceUpNull")
+                .description("Whether to ignore children reporting null values for service up "
+                        + "(i.e. don't treat them as 'down' when computing our own 'service.isUp')")
+                .defaultValue(true)
+                .build();
+
+        static final Set<ConfigKey<?>> RECONFIGURABLE_KEYS = ImmutableSet.<ConfigKey<?>>of(
+                UP_QUORUM_CHECK, RUNNING_QUORUM_CHECK,
+                ENTITY_IGNORED_STATES_ON_STARTING, ENTITY_IGNORED_STATES_ON_OTHERS,
+                ENTITY_FAILED_STATES, ENTITY_TRANSITION_STATES_ON_STARTING);
+
+        static final List<Sensor<?>> SOURCE_SENSORS = ImmutableList.<Sensor<?>>of(SERVICE_UP, SERVICE_STATE_ACTUAL);
+
+        @Override
+        public AsyncApplicationImpl getEntity() {
+            return (AsyncApplicationImpl) super.getEntity();
+        }
+        
+        @Override
+        protected void setEntityLoadingTargetConfig() {
+            // ensure parent's behaviour never happens
+            if (getConfig(TARGET_SENSOR)!=null)
+                throw new IllegalArgumentException("Must not set "+TARGET_SENSOR+" when using "+this);
+        }
+
+        @Override
+        public void setEntity(EntityLocal entity) {
+            if (!(entity instanceof AsyncApplicationImpl)) {
+                throw new IllegalArgumentException("enricher designed to work only with async-apps");
+            }
+            if (!isRebinding() && Boolean.FALSE.equals(config().get(SUPPRESS_DUPLICATES))) {
+                throw new IllegalArgumentException("Must not set "+SUPPRESS_DUPLICATES+" to false when using "+this);
+            }
+            super.setEntity(entity);
+            if (suppressDuplicates==null) {
+                // only publish on changes, unless it is configured otherwise
+                suppressDuplicates = true;
+            }
+            
+            // Need to update again, e.g. if stop() effector marks this as expected=stopped.
+            // There'd be a risk of infinite loop if we didn't suppressDuplicates!
+            subscriptions().subscribe(entity, Attributes.SERVICE_STATE_EXPECTED, new SensorEventListener<Lifecycle.Transition>() {
+                @Override public void onEvent(SensorEvent<Lifecycle.Transition> event) {
+                    onUpdated();
+                }});
+        }
+
+        @Override
+        protected <T> void doReconfigureConfig(ConfigKey<T> key, T val) {
+            if (RECONFIGURABLE_KEYS.contains(key)) {
+                return;
+            } else {
+                super.doReconfigureConfig(key, val);
+            }
+        }
+
+        @Override
+        protected void onChanged() {
+            super.onChanged();
+            onUpdated();
+        }
+
+        @Override
+        protected Collection<Sensor<?>> getSourceSensors() {
+            return SOURCE_SENSORS;
+        }
+
+        @Override
+        protected void onUpdated() {
+            if (entity == null || !isRunning() || !Entities.isManaged(entity)) {
+                // e.g. invoked during setup or entity has become unmanaged; just ignore
+                BrooklynLogging.log(LOG, BrooklynLogging.levelDebugOrTraceIfReadOnly(entity),
+                    "Ignoring {} onUpdated when entity is not in valid state ({})", this, entity);
+                return;
+            }
+
+            Lifecycle.Transition oldExpectedStateTransition = entity.sensors().get(Attributes.SERVICE_STATE_EXPECTED);
+            Lifecycle oldExpectedState = (oldExpectedStateTransition != null) ? oldExpectedStateTransition.getState() : null;
+            
+            ValueAndReason<Boolean> newServiceUp = computeServiceUp(oldExpectedState);
+            ValueAndReason<Lifecycle> newServiceState = computeServiceState(oldExpectedState);
+            Lifecycle newExpectedState = computeExpectedState(oldExpectedState, newServiceState.val);
+
+            emit(Attributes.SERVICE_STATE_ACTUAL, newServiceState.val);
+            emit(Attributes.SERVICE_UP, newServiceUp.val);
+            
+            if (Boolean.TRUE.equals(newServiceUp.val)) {
+                clearMapSensorEntry(entity, Attributes.SERVICE_NOT_UP_INDICATORS, DEFAULT_UNIQUE_TAG);
+            } else {
+                updateMapSensorEntry(entity, Attributes.SERVICE_NOT_UP_INDICATORS, DEFAULT_UNIQUE_TAG, newServiceUp.reason);
+            }
+            if (newServiceState.val != null && newServiceState.val == Lifecycle.ON_FIRE) {
+                updateMapSensorEntry(entity, Attributes.SERVICE_PROBLEMS, DEFAULT_UNIQUE_TAG, newServiceState.reason);
+            } else {
+                clearMapSensorEntry(entity, Attributes.SERVICE_PROBLEMS, DEFAULT_UNIQUE_TAG);
+            }
+            
+            if (oldExpectedState != newExpectedState) {
+                // TODO could check no-one else has changed expectedState (e.g. by calling "stop")
+                // TODO do we need to subscribe to our own serviceStateExpected, in case someone calls stop?
+                getEntity().setExpectedStateAndRecordLifecycleEvent(newExpectedState);
+            }
+        }
+
+        /**
+         * Count the entities that are up versus not up. Compare this with the quorum required.
+         */
+        protected ValueAndReason<Boolean> computeServiceUp(Lifecycle expectedState) {
+            boolean ignoreNull = getConfig(IGNORE_ENTITIES_WITH_SERVICE_UP_NULL);
+            Set<Lifecycle> ignoredStates;
+            if (expectedState == Lifecycle.STARTING) {
+                ignoredStates = getConfig(ENTITY_IGNORED_STATES_ON_STARTING);
+            } else {
+                ignoredStates = getConfig(ENTITY_IGNORED_STATES_ON_OTHERS);
+            }
+            
+            Map<Entity, Boolean> values = getValues(SERVICE_UP);
+            List<Entity> violators = MutableList.of();
+            
+            int entries = 0;
+            int numUp = 0;
+            for (Map.Entry<Entity, Boolean> entry : values.entrySet()) {
+                Lifecycle entityState = entry.getKey().getAttribute(SERVICE_STATE_ACTUAL);
+                
+                if (ignoreNull && entry.getValue()==null) {
+                    continue;
+                }
+                if (ignoredStates.contains(entityState)) {
+                    continue;
+                }
+                entries++;
+
+                if (Boolean.TRUE.equals(entry.getValue())) {
+                    numUp++;
+                } else {
+                    violators.add(entry.getKey());
+                }
+            }
+
+            QuorumCheck qc = getRequiredConfig(UP_QUORUM_CHECK);
+            if (qc.isQuorate(numUp, violators.size()+numUp)) {
+                // quorate
+                return new ValueAndReason<>(Boolean.TRUE, "quorate");
+            }
+            
+            String reason;
+            if (values.isEmpty()) {
+                reason = "No entities present";
+            } else if (entries == 0) {
+                reason = "No entities (in correct state) publishing service up";
+            } else if (violators.isEmpty()) {
+                reason = "Not enough entities";
+            } else if (violators.size() == 1) {
+                reason = violators.get(0)+" is not up";
+            } else if (violators.size() == entries) {
+                reason = "None of the entities are up";
+            } else {
+                reason = violators.size()+" entities are not up, including "+violators.get(0);
+            }            
+            return new ValueAndReason<>(Boolean.FALSE, reason);
+        }
+
+        protected ValueAndReason<Lifecycle> computeServiceState(Lifecycle expectedState) {
+            if (expectedState != null && (expectedState != Lifecycle.STARTING && expectedState != Lifecycle.RUNNING)) {
+                return new ValueAndReason<>(expectedState, "expected state "+expectedState);
+            }
+            
+            Set<Lifecycle> ignoredStates;
+            if (expectedState == Lifecycle.STARTING) {
+                ignoredStates = getConfig(ENTITY_IGNORED_STATES_ON_STARTING);
+            } else {
+                ignoredStates = getConfig(ENTITY_IGNORED_STATES_ON_OTHERS);
+            }
+            Set<Lifecycle> transitionStates;
+            if (expectedState == Lifecycle.STARTING) {
+                transitionStates = getConfig(ENTITY_TRANSITION_STATES_ON_STARTING);
+            } else {
+                transitionStates = ImmutableSet.of();
+            }
+
+            Map<Entity, Lifecycle> values = getValues(SERVICE_STATE_ACTUAL);
+            List<Entity> violators = MutableList.of();
+            int entries = 0;
+            int numRunning = 0;
+            int numTransitioning = 0;
+            
+            for (Map.Entry<Entity,Lifecycle> entry : values.entrySet()) {
+                if (ignoredStates.contains(entry.getValue())) {
+                    continue;
+                }
+                entries++;
+                
+                if (entry.getValue() == Lifecycle.RUNNING) {
+                    numRunning++;
+                } else if (transitionStates.contains(entry.getValue())) {
+                    numTransitioning++;
+                } else {
+                    violators.add(entry.getKey());
+                }
+            }
+
+            QuorumCheck qc = getConfig(RUNNING_QUORUM_CHECK);
+            if (qc.isQuorate(numRunning, violators.size()+numRunning+numTransitioning)) {
+                // quorate
+                return new ValueAndReason<Lifecycle>(Lifecycle.RUNNING, "quorate");
+            }
+            boolean canEverBeQuorate = qc.isQuorate(numRunning+numTransitioning, violators.size()+numRunning+numTransitioning);
+            if (expectedState == Lifecycle.STARTING && canEverBeQuorate) {
+                // quorate
+                return new ValueAndReason<Lifecycle>(Lifecycle.STARTING, "not yet quorate");
+            }
+            
+            String reason;
+            if (values.isEmpty()) {
+                reason = "No entities present";
+            } else if (entries == 0) {
+                reason = "No entities in interesting states";
+            } else if (violators.isEmpty()) {
+                reason = "Not enough entities";
+            } else if (violators.size() == 1) {
+                reason = violators.get(0)+" is not healthy";
+            } else if (violators.size() == entries) {
+                reason = "None of the entities are healthy";
+            } else {
+                reason = violators.size()+" entities are not healthy, including "+violators.get(0);
+            }            
+            return new ValueAndReason<>(Lifecycle.ON_FIRE, reason);
+
+        }
+
+        protected Lifecycle computeExpectedState(Lifecycle oldExpectedState, Lifecycle newActualState) {
+            if (oldExpectedState != Lifecycle.STARTING) {
+                return oldExpectedState;
+            }
+            
+            // Are any of our children still starting?
+            Map<Entity, Lifecycle> values = getValues(SERVICE_STATE_ACTUAL);
+            boolean childIsStarting = values.containsValue(Lifecycle.STARTING) || values.containsValue(Lifecycle.CREATED) || values.containsValue(null);
+
+            if (!childIsStarting) {
+                // We only transition to expected=RUNNING if all our children are no longer starting.
+                // When actual=running, this matters if quorum != all;
+                // When actual=on_fire, this matters for recovering.
+                return Lifecycle.RUNNING;
+            }
+            return oldExpectedState;
+        }
+        
+        /** not used; see specific `computeXxx` methods, invoked by overridden onUpdated */
+        @Override
+        protected Object compute() {
+            return null;
+        }
+        
+        static class ValueAndReason<T> {
+            final T val;
+            final String reason;
+            
+            ValueAndReason(T val, String reason) {
+                this.val = val;
+                this.reason = reason;
+            }
+            
+            @Override
+            public String toString() {
+                return MoreObjects.toStringHelper(this).add("val", val).add("reason", reason).toString();
+            }
+        }
+    }
+}

http://git-wip-us.apache.org/repos/asf/brooklyn-server/blob/13c81772/core/src/test/java/org/apache/brooklyn/core/entity/EntityAsyncTest.java
----------------------------------------------------------------------
diff --git a/core/src/test/java/org/apache/brooklyn/core/entity/EntityAsyncTest.java b/core/src/test/java/org/apache/brooklyn/core/entity/EntityAsyncTest.java
new file mode 100644
index 0000000..0a59867
--- /dev/null
+++ b/core/src/test/java/org/apache/brooklyn/core/entity/EntityAsyncTest.java
@@ -0,0 +1,361 @@
+/*
+ * 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.
+ */
+package org.apache.brooklyn.core.entity;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.brooklyn.api.entity.Entity;
+import org.apache.brooklyn.api.entity.EntitySpec;
+import org.apache.brooklyn.api.entity.ImplementedBy;
+import org.apache.brooklyn.api.location.Location;
+import org.apache.brooklyn.core.entity.lifecycle.Lifecycle;
+import org.apache.brooklyn.core.entity.lifecycle.Lifecycle.Transition;
+import org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic;
+import org.apache.brooklyn.core.entity.trait.Startable;
+import org.apache.brooklyn.core.entity.trait.StartableMethods;
+import org.apache.brooklyn.core.test.BrooklynAppUnitTestSupport;
+import org.apache.brooklyn.entity.group.AbstractGroupImpl;
+import org.apache.brooklyn.entity.group.Cluster;
+import org.apache.brooklyn.test.Asserts;
+import org.apache.brooklyn.util.collections.QuorumCheck;
+import org.apache.brooklyn.util.exceptions.Exceptions;
+import org.apache.brooklyn.util.time.Duration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testng.annotations.Test;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+
+/**
+ * Tests a pattern that a user has, for async entities.
+ * 
+ * Calling start() triggers some asynchronous work. Completion of that work is reported via a
+ * callback that indicates success or fail.
+ */
+public class EntityAsyncTest extends BrooklynAppUnitTestSupport {
+
+    // TODO If the cluster has quorum=all, should the cluster report ON_FIRE as soon as any of the
+    // children report a failure to start, or should it keep saying "STARTING" until all callbacks
+    // are received?
+    
+    private static final Logger LOG = LoggerFactory.getLogger(EntityAsyncTest.class);
+
+    @Test
+    public void testEntityStartsAsynchronously() throws Exception {
+        AsyncEntity entity = app.addChild(EntitySpec.create(AsyncEntity.class));
+        
+        app.start(ImmutableList.of());
+        assertStateEventually(entity, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        entity.onCallback(true);
+        assertStateEventually(entity, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+    
+    @Test
+    public void testClusterStartsAsynchronously() throws Exception {
+        AsyncCluster cluster = app.addChild(EntitySpec.create(AsyncCluster.class)
+                .configure(AsyncCluster.INITIAL_SIZE, 2));
+        
+        app.start(ImmutableList.of());
+        List<AsyncEntity> children = cast(cluster.getChildren(), AsyncEntity.class);
+        
+        // Everything should say "starting"
+        assertStateEventually(cluster, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        for (AsyncEntity child : children) {
+            assertStateEventually(child, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        }
+
+        // Indicate that first child is now running successfully
+        cluster.onCallback(children.get(0).getId(), true);
+        
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(children.get(1), Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateContinually(cluster, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        // Indicate that second child is now running successfully
+        cluster.onCallback(children.get(1).getId(), true);
+        
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(children.get(1), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(cluster, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+    
+    @Test
+    public void testClusterFirstChildFails() throws Exception {
+        AsyncCluster cluster = app.addChild(EntitySpec.create(AsyncCluster.class)
+                .configure(AsyncCluster.INITIAL_SIZE, 2));
+        
+        app.start(ImmutableList.of());
+        List<AsyncEntity> children = cast(cluster.getChildren(), AsyncEntity.class);
+        
+        // Everything should say "starting"
+        assertStateEventually(cluster, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        for (AsyncEntity child : children) {
+            assertStateEventually(child, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        }
+
+        // Indicate that first child failed
+        cluster.onCallback(children.get(0).getId(), false);
+        
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(children.get(1), Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(cluster, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        // Indicate that second child is now running successfully
+        cluster.onCallback(children.get(1).getId(), true);
+        
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(children.get(1), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(cluster, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+    }
+    
+    @Test
+    public void testClusterSecondChildFails() throws Exception {
+        AsyncCluster cluster = app.addChild(EntitySpec.create(AsyncCluster.class)
+                .configure(AsyncCluster.INITIAL_SIZE, 2));
+        
+        app.start(ImmutableList.of());
+        List<AsyncEntity> children = cast(cluster.getChildren(), AsyncEntity.class);
+        
+        // Everything should say "starting"
+        assertStateEventually(cluster, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        for (AsyncEntity child : children) {
+            assertStateEventually(child, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        }
+
+        // Indicate that first child is now running successfully
+        cluster.onCallback(children.get(0).getId(), true);
+        
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(children.get(1), Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateContinually(cluster, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        // Indicate that second child failed
+        cluster.onCallback(children.get(1).getId(), false);
+        
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(children.get(1), Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(cluster, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+    }
+    
+    @Test
+    public void testClusterStartsThenChildFails() throws Exception {
+        AsyncCluster cluster = app.addChild(EntitySpec.create(AsyncCluster.class)
+                .configure(AsyncCluster.INITIAL_SIZE, 2));
+        
+        app.start(ImmutableList.of());
+        List<AsyncEntity> children = cast(cluster.getChildren(), AsyncEntity.class);
+        
+        // Everything should say "starting"
+        assertStateEventually(cluster, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        for (AsyncEntity child : children) {
+            assertStateEventually(child, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        }
+
+        // Indicate that children are now running successfully
+        cluster.onCallback(children.get(0).getId(), true);
+        cluster.onCallback(children.get(1).getId(), true);
+        
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(children.get(1), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(cluster, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+
+        // First child then fails
+        ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(children.get(0), "myKey", "simulate failure");
+
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(children.get(1), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(cluster, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        
+        // First child then recovers
+        ServiceStateLogic.ServiceNotUpLogic.clearNotUpIndicator(children.get(0), "myKey");
+        
+        assertStateEventually(children.get(0), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(children.get(1), Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(cluster, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+    
+    private void assertStateContinually(final Entity entity, final Lifecycle expectedState, final Lifecycle state, final Boolean isUp) {
+        Asserts.succeedsContinually(ImmutableMap.of("timeout", Duration.millis(50)), new Runnable() {
+            @Override public void run() {
+                assertState(entity, expectedState, state, isUp);
+            }});
+    }
+    
+    private void assertStateEventually(Entity entity, Lifecycle expectedState, Lifecycle state, Boolean isUp) {
+        Asserts.succeedsEventually(new Runnable() {
+            @Override public void run() {
+                assertState(entity, expectedState, state, isUp);
+            }});
+    }
+    
+    private void assertState(Entity entity, Lifecycle expectedState, Lifecycle state, Boolean isUp) {
+        Transition actualExpectedState = entity.sensors().get(Attributes.SERVICE_STATE_EXPECTED);
+        Lifecycle actualState = entity.sensors().get(Attributes.SERVICE_STATE_ACTUAL);
+        Boolean actualIsUp = entity.sensors().get(Attributes.SERVICE_UP);
+        String msg = "actualExpectedState="+actualExpectedState+", actualState="+actualState+", actualIsUp="+actualIsUp;
+        if (expectedState != null) {
+            assertEquals(actualExpectedState.getState(), expectedState, msg);
+        } else {
+            assertTrue(actualExpectedState == null || actualExpectedState.getState() == null, msg);
+        }
+        assertEquals(actualState, state, msg);
+        assertEquals(actualIsUp, isUp, msg);
+    }
+    
+    private <T> List<T> cast(Iterable<?> vals, Class<T> clazz) {
+        List<T> result = Lists.newArrayList();
+        for (Object val : vals) {
+            result.add(clazz.cast(val));
+        }
+        return result;
+    }
+
+    /**
+     * The AsyncEntity's start leaves it in a "STARTING" state.
+     * 
+     * It stays like that until {@code onCallback(true)} is called. It should then report 
+     * expected="RUNNING", service.state="RUNNING" and service.isUp=true. Alternatively, 
+     * if {@code onCallback(true)} is called, it should then report expected="RUNNING", 
+     * service.state="ON_FIRE" and service.isUp=false.
+     */
+    @ImplementedBy(AsyncEntityImpl.class)
+    public interface AsyncEntity extends Entity, Startable {
+        void onCallback(boolean success);
+    }
+    
+    public static class AsyncEntityImpl extends AbstractEntity implements AsyncEntity {
+
+        @Override
+        public void start(Collection<? extends Location> locations) {
+            ServiceStateLogic.setExpectedState(this, Lifecycle.STARTING);
+            ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, START.getName(), "starting");
+        }
+
+
+        @Override
+        public void onCallback(boolean success) {
+            if (success) {
+                ServiceStateLogic.ServiceNotUpLogic.clearNotUpIndicator(this, START.getName());
+            } else {
+                ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, START.getName(), "callback reported failure");
+            }
+            
+            Transition expectedState = sensors().get(Attributes.SERVICE_STATE_EXPECTED);
+            if (expectedState != null && expectedState.getState() == Lifecycle.STARTING) {
+                ServiceStateLogic.setExpectedState(this, Lifecycle.RUNNING);
+            }
+        }
+
+        @Override
+        public void stop() {
+        }
+
+        @Override
+        public void restart() {
+        }
+    }
+    
+    /**
+     * The AsyncCluster's start leaves it in a "STARTING" state, having created the children
+     * and called start() on each.
+     * 
+     * It expects an explicit call to {@code clearNotUpIndicator(); setExpected(Lifecycle.RUNNING)},
+     * after which its service.isUp will be inferred from its children (they must all be running).
+     */
+    @ImplementedBy(AsyncClusterImpl.class)
+    public interface AsyncCluster extends Cluster, Startable {
+        void onCallback(String childId, boolean success);
+    }
+    
+    public static class AsyncClusterImpl extends AbstractGroupImpl implements AsyncCluster {
+
+        @Override
+        protected void initEnrichers() {
+            super.initEnrichers();
+            
+            // all children must be up, for ourselves to be up
+            enrichers().add(ServiceStateLogic.newEnricherFromChildrenUp()
+                    .checkChildrenOnly()
+                    .requireUpChildren(QuorumCheck.QuorumChecks.all()));
+        }
+
+        @Override
+        public void start(Collection<? extends Location> locations) {
+            ServiceStateLogic.setExpectedState(this, Lifecycle.STARTING);
+            ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, START.getName(), "starting");
+            
+            try {
+                for (int i = 0; i < config().get(INITIAL_SIZE); i++) {
+                    addChild(EntitySpec.create(AsyncEntity.class));
+                }
+                StartableMethods.start(this, locations);
+                
+            } catch (Throwable t) {
+                ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, START.getName(), Exceptions.collapseText(t));
+                ServiceStateLogic.setExpectedState(this, Lifecycle.ON_FIRE);
+                throw Exceptions.propagate(t);
+            }
+        }
+
+        @Override
+        public void onCallback(String childId, boolean success) {
+            Optional<Entity> child = Iterables.tryFind(getChildren(), EntityPredicates.idEqualTo(childId));
+            if (child.isPresent()) {
+                ((AsyncEntity)child.get()).onCallback(success);
+            } else {
+                LOG.warn("Child not found with resourceId '"+childId+"'; not injecting state from callback");
+            }
+            
+            Optional<Entity> unstartedVm = Iterables.tryFind(getChildren(), EntityPredicates.attributeSatisfies(Attributes.SERVICE_STATE_EXPECTED, 
+                    new Predicate<Lifecycle.Transition>() {
+                        @Override public boolean apply(Transition input) {
+                            return input == null || input.getState() == Lifecycle.STARTING;
+                        }}));
+            
+            if (!unstartedVm.isPresent()) {
+                // No VMs are still starting; we are finished starting
+                ServiceStateLogic.ServiceNotUpLogic.clearNotUpIndicator(this, START.getName());
+                ServiceStateLogic.setExpectedState(this, Lifecycle.RUNNING);
+            }
+        }
+        
+        @Override
+        public void stop() {
+        }
+
+        @Override
+        public void restart() {
+        }
+
+        @Override
+        public Integer resize(Integer desiredSize) {
+            throw new UnsupportedOperationException();
+        }
+    }
+}

http://git-wip-us.apache.org/repos/asf/brooklyn-server/blob/13c81772/core/src/test/java/org/apache/brooklyn/entity/stock/AsyncApplicationTest.java
----------------------------------------------------------------------
diff --git a/core/src/test/java/org/apache/brooklyn/entity/stock/AsyncApplicationTest.java b/core/src/test/java/org/apache/brooklyn/entity/stock/AsyncApplicationTest.java
new file mode 100644
index 0000000..8ca07cf
--- /dev/null
+++ b/core/src/test/java/org/apache/brooklyn/entity/stock/AsyncApplicationTest.java
@@ -0,0 +1,457 @@
+/*
+ * 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.
+ */
+package org.apache.brooklyn.entity.stock;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+import java.util.Collection;
+
+import org.apache.brooklyn.api.entity.Entity;
+import org.apache.brooklyn.api.entity.EntitySpec;
+import org.apache.brooklyn.api.entity.ImplementedBy;
+import org.apache.brooklyn.api.location.Location;
+import org.apache.brooklyn.core.entity.AbstractEntity;
+import org.apache.brooklyn.core.entity.Attributes;
+import org.apache.brooklyn.core.entity.Entities;
+import org.apache.brooklyn.core.entity.lifecycle.Lifecycle;
+import org.apache.brooklyn.core.entity.lifecycle.Lifecycle.Transition;
+import org.apache.brooklyn.core.entity.lifecycle.ServiceStateLogic;
+import org.apache.brooklyn.core.entity.trait.FailingEntity;
+import org.apache.brooklyn.core.entity.trait.Startable;
+import org.apache.brooklyn.core.test.BrooklynMgmtUnitTestSupport;
+import org.apache.brooklyn.core.test.entity.TestEntity;
+import org.apache.brooklyn.test.Asserts;
+import org.apache.brooklyn.util.collections.QuorumCheck.QuorumChecks;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+public class AsyncApplicationTest extends BrooklynMgmtUnitTestSupport {
+
+    AsyncApplication app;
+    
+    @BeforeMethod(alwaysRun=true)
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        app = mgmt.getEntityManager().createEntity(EntitySpec.create(AsyncApplication.class));
+    }
+    
+    // FIXME This fails because the enricher is never triggered (there are no child events to trigger it)
+    @Test(enabled=false, groups="WIP")
+    public void testStartEmptyApp() throws Exception {
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+
+    @Test
+    public void testStartAndStopWithVanillaChild() throws Exception {
+        app = mgmt.getEntityManager().createEntity(EntitySpec.create(AsyncApplication.class)
+                .configure(AsyncApplication.DESTROY_ON_STOP, false));
+        TestEntity child = app.addChild(EntitySpec.create(TestEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(child, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        
+        app.stop();
+        
+        assertTrue(Entities.isManaged(app));
+        assertTrue(Entities.isManaged(child));
+        assertStateEventually(child, Lifecycle.STOPPED, Lifecycle.STOPPED, false);
+        assertStateEventually(app, Lifecycle.STOPPED, Lifecycle.STOPPED);
+    }
+    
+    @Test
+    public void testStopWillUnmanage() throws Exception {
+        TestEntity child = app.addChild(EntitySpec.create(TestEntity.class));
+        app.start(ImmutableList.of());
+        app.stop();
+        
+        assertFalse(Entities.isManaged(app));
+        assertFalse(Entities.isManaged(child));
+    }
+    
+    @Test
+    public void testStartWithVanillaFailingChild() throws Exception {
+        app.addChild(EntitySpec.create(FailingEntity.class)
+                .configure(FailingEntity.FAIL_ON_START, true));
+        try {
+            app.start(ImmutableList.of());
+            Asserts.shouldHaveFailedPreviously();
+        } catch (Exception e) {
+            Asserts.expectedFailureContains(e, "Simulating entity start failure for test");
+        }
+        
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+    }
+
+    @Test
+    public void testStartWithAsyncChild() throws Exception {
+        AsyncEntity child = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        child.clearNotUpIndicators();
+        child.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+    
+    @Test
+    public void testStartWithAsyncFailingChild() throws Exception {
+        AsyncEntity child = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        child.addNotUpIndicator("simulatedFailure", "my failure");
+        child.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+    }
+    
+    @Test
+    public void testStartWithAsyncChildren() throws Exception {
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child starts
+        child1.clearNotUpIndicators();
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        // Second child starts
+        child2.clearNotUpIndicators();
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+    
+    @Test
+    public void testStartWithAsyncChildrenFirstChildFails() throws Exception {
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child fails
+        child1.addNotUpIndicator("simulatedFailure", "my failure");
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.ON_FIRE, false);
+        
+        // Second child starts
+        child2.clearNotUpIndicators();
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+    }
+
+    @Test
+    public void testStartWithAsyncChildrenFirstChildFailsThenRecovers() throws Exception {
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child fails
+        child1.addNotUpIndicator("simulatedFailure", "my failure");
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.ON_FIRE, false);
+        
+        // First child recovers
+        child1.clearNotUpIndicators();
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        // Second child starts
+        child2.clearNotUpIndicators();
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+
+    @Test
+    public void testStartWithAsyncChildrenFirstChildFailsThenAfterSecondItRecovers() throws Exception {
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child fails
+        child1.addNotUpIndicator("simulatedFailure", "my failure");
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.ON_FIRE, false);
+        
+        // Second child starts
+        child2.clearNotUpIndicators();
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        
+        // First child recovers
+        child1.clearNotUpIndicators();
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+
+    @Test
+    public void testStartWithAsyncChildrenFirstChildFailsThenRecoversImmediately() throws Exception {
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child fails
+        child1.addNotUpIndicator("simulatedFailure", "my failure");
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.ON_FIRE, false);
+        
+        // First child recovers
+        child1.clearNotUpIndicators();
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        // Second child starts
+        child2.clearNotUpIndicators();
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+
+    @Test
+    public void testStartWithAsyncChildrenLastChildFails() throws Exception {
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child starts
+        child1.clearNotUpIndicators();
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        // Seconds child fails
+        child2.addNotUpIndicator("simulatedFailure", "my failure");
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+    }
+
+    @Test
+    public void testStartWithQuorumOne() throws Exception {
+        app = mgmt.getEntityManager().createEntity(EntitySpec.create(AsyncApplication.class)
+                .configure(AsyncApplication.RUNNING_QUORUM_CHECK, QuorumChecks.atLeastOne())
+                .configure(AsyncApplication.UP_QUORUM_CHECK, QuorumChecks.atLeastOne()));
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child starts
+        child1.clearNotUpIndicators();
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.RUNNING, true);
+        
+        // Seconds child starts
+        child2.clearNotUpIndicators();
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+
+    @Test
+    public void testStartWithQuorumOneFirstChildFails() throws Exception {
+        app = mgmt.getEntityManager().createEntity(EntitySpec.create(AsyncApplication.class)
+                .configure(AsyncApplication.RUNNING_QUORUM_CHECK, QuorumChecks.atLeastOne())
+                .configure(AsyncApplication.UP_QUORUM_CHECK, QuorumChecks.atLeastOne()));
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child starts
+        child1.addNotUpIndicator("simulatedFailure", "my failure");
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        
+        // Seconds child starts
+        child2.clearNotUpIndicators();
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+
+    @Test
+    public void testStartWithQuorumOneSecondChildFails() throws Exception {
+        app = mgmt.getEntityManager().createEntity(EntitySpec.create(AsyncApplication.class)
+                .configure(AsyncApplication.RUNNING_QUORUM_CHECK, QuorumChecks.atLeastOne())
+                .configure(AsyncApplication.UP_QUORUM_CHECK, QuorumChecks.atLeastOne()));
+        AsyncEntity child1 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        AsyncEntity child2 = app.addChild(EntitySpec.create(AsyncEntity.class));
+        app.start(ImmutableList.of());
+        
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.STARTING, false);
+
+        // First child starts
+        child1.clearNotUpIndicators();
+        child1.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.STARTING, Lifecycle.STARTING, false);
+        assertStateEventually(app, Lifecycle.STARTING, Lifecycle.RUNNING, true);
+        
+        // Seconds child starts
+        child2.addNotUpIndicator("simulatedFailure", "my failure");
+        child2.setExpected(Lifecycle.RUNNING);
+        assertStateEventually(child1, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+        assertStateEventually(child2, Lifecycle.RUNNING, Lifecycle.ON_FIRE, false);
+        assertStateEventually(app, Lifecycle.RUNNING, Lifecycle.RUNNING, true);
+    }
+
+    private void assertStateEventually(Entity entity, Lifecycle expectedState, Lifecycle state, Boolean isUp) {
+        Asserts.succeedsEventually(new Runnable() {
+            @Override public void run() {
+                assertState(entity, expectedState, state, isUp);
+            }});
+    }
+    
+    private void assertState(Entity entity, Lifecycle expectedState, Lifecycle state, Boolean isUp) {
+        Transition actualExpectedState = entity.sensors().get(Attributes.SERVICE_STATE_EXPECTED);
+        Lifecycle actualState = entity.sensors().get(Attributes.SERVICE_STATE_ACTUAL);
+        Boolean actualIsUp = entity.sensors().get(Attributes.SERVICE_UP);
+        String msg = "actualExpectedState="+actualExpectedState+", actualState="+actualState+", actualIsUp="+actualIsUp;
+        if (expectedState != null) {
+            assertEquals(actualExpectedState.getState(), expectedState, msg);
+        } else {
+            assertTrue(actualExpectedState == null || actualExpectedState.getState() == null, msg);
+        }
+        assertEquals(actualState, state, msg);
+        assertEquals(actualIsUp, isUp, msg);
+    }
+    
+    private void assertStateEventually(Entity entity, Lifecycle expectedState, Lifecycle state) {
+        Asserts.succeedsEventually(new Runnable() {
+            @Override public void run() {
+                assertState(entity, expectedState, state);
+            }});
+    }
+    
+    private void assertState(Entity entity, Lifecycle expectedState, Lifecycle state) {
+        Transition actualExpectedState = entity.sensors().get(Attributes.SERVICE_STATE_EXPECTED);
+        Lifecycle actualState = entity.sensors().get(Attributes.SERVICE_STATE_ACTUAL);
+        Boolean actualIsUp = entity.sensors().get(Attributes.SERVICE_UP);
+        String msg = "actualExpectedState="+actualExpectedState+", actualState="+actualState+", actualIsUp="+actualIsUp;
+        if (expectedState != null) {
+            assertEquals(actualExpectedState.getState(), expectedState, msg);
+        } else {
+            assertTrue(actualExpectedState == null || actualExpectedState.getState() == null, msg);
+        }
+        assertEquals(actualState, state, msg);
+    }
+    
+    /**
+     * The AsyncEntity's start leaves it in a "STARTING" state.
+     * 
+     * It stays like that until {@code clearNotUpIndicator(); setExpected(Lifecycle.RUNNING)} is 
+     * called. It should then report "RUNNING" and service.isUp=true.
+     */
+    @ImplementedBy(AsyncEntityImpl.class)
+    public interface AsyncEntity extends Entity, Startable {
+        void setExpected(Lifecycle state);
+        void addNotUpIndicator(String label, String val);
+        void clearNotUpIndicators();
+    }
+    
+    public static class AsyncEntityImpl extends AbstractEntity implements AsyncEntity {
+
+        @Override
+        public void start(Collection<? extends Location> locations) {
+            ServiceStateLogic.setExpectedState(this, Lifecycle.STARTING);
+            ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, START.getName(), "starting");
+        }
+
+
+        @Override
+        public void setExpected(Lifecycle state) {
+            ServiceStateLogic.setExpectedState(this, checkNotNull(state, "state"));
+        }
+
+        @Override
+        public void clearNotUpIndicators() {
+            sensors().set(Attributes.SERVICE_NOT_UP_INDICATORS, ImmutableMap.of());
+        }
+        
+        @Override
+        public void addNotUpIndicator(String label, String val) {
+            ServiceStateLogic.ServiceNotUpLogic.updateNotUpIndicator(this, label, val);
+        }
+        
+        @Override
+        public void stop() {
+        }
+
+        @Override
+        public void restart() {
+        }
+    }
+}


[3/3] brooklyn-server git commit: This closes #761

Posted by dr...@apache.org.
This closes #761


Project: http://git-wip-us.apache.org/repos/asf/brooklyn-server/repo
Commit: http://git-wip-us.apache.org/repos/asf/brooklyn-server/commit/c10fd58e
Tree: http://git-wip-us.apache.org/repos/asf/brooklyn-server/tree/c10fd58e
Diff: http://git-wip-us.apache.org/repos/asf/brooklyn-server/diff/c10fd58e

Branch: refs/heads/master
Commit: c10fd58eb0613486ecdbd43f463c645e596e3141
Parents: ee55149 13c8177
Author: Duncan Godwin <dr...@googlemail.com>
Authored: Fri Jul 14 14:53:11 2017 +0100
Committer: Duncan Godwin <dr...@googlemail.com>
Committed: Fri Jul 14 14:53:11 2017 +0100

----------------------------------------------------------------------
 .../core/entity/AbstractApplication.java        |   2 +-
 .../core/entity/trait/AsyncStartable.java       |  34 ++
 .../mgmt/rebind/BasicEntityRebindSupport.java   |   5 +-
 .../brooklyn/entity/stock/AsyncApplication.java |  32 ++
 .../entity/stock/AsyncApplicationImpl.java      | 479 +++++++++++++++++++
 .../brooklyn/core/entity/EntityAsyncTest.java   | 361 ++++++++++++++
 .../entity/stock/AsyncApplicationTest.java      | 457 ++++++++++++++++++
 ...ftwareProcessRebindNotRunningEntityTest.java |  69 +++
 8 files changed, 1437 insertions(+), 2 deletions(-)
----------------------------------------------------------------------