/* * Copyright © 2015 Cask Data, Inc. * * Licensed 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 co.cask.cdap.test.app; import co.cask.cdap.api.common.Bytes; import co.cask.cdap.api.dataset.lib.KeyValueTable; import co.cask.cdap.api.dataset.lib.cube.AggregationFunction; import co.cask.cdap.api.metrics.MetricDataQuery; import co.cask.cdap.api.metrics.MetricTimeSeries; import co.cask.cdap.common.conf.Constants; import co.cask.cdap.common.utils.ImmutablePair; import co.cask.cdap.common.utils.Tasks; import co.cask.cdap.internal.app.services.ServiceHttpServer; import co.cask.cdap.proto.Id; import co.cask.cdap.test.ApplicationManager; import co.cask.cdap.test.DataSetManager; import co.cask.cdap.test.ServiceManager; import co.cask.cdap.test.base.TestFrameworkTestBase; import com.google.common.base.Charsets; import com.google.common.base.Throwables; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.LinkedListMultimap; import com.google.common.collect.Multimap; import com.google.common.io.ByteStreams; import com.google.common.reflect.TypeToken; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import com.google.gson.Gson; import org.junit.Assert; import org.junit.ClassRule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.lang.reflect.Type; import java.net.HttpURLConnection; import java.net.URL; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; /** * Unit test for testing service handler lifecycle. */ public class ServiceLifeCycleTestRun extends TestFrameworkTestBase { @ClassRule public static final TemporaryFolder TEMP_FOLDER = new TemporaryFolder(); private static final Gson GSON = new Gson(); private static final Type STATES_TYPE = new TypeToken<List<ImmutablePair<Integer, String>>>() { }.getType(); @Test public void testLifecycleWithThreadTerminates() throws Exception { // Set the http server properties to speed up test System.setProperty(ServiceHttpServer.THREAD_POOL_SIZE, "1"); System.setProperty(ServiceHttpServer.THREAD_KEEP_ALIVE_SECONDS, "1"); System.setProperty(ServiceHttpServer.HANDLER_CLEANUP_PERIOD_MILLIS, "100"); try { ApplicationManager appManager = deployApplication(ServiceLifecycleApp.class); final ServiceManager serviceManager = appManager.getServiceManager("test").start(); // Make a call to the service, expect an init state Multimap<Integer, String> states = getStates(serviceManager); Assert.assertEquals(1, states.size()); int handlerHashCode = states.keySet().iterator().next(); Assert.assertEquals(ImmutableList.of("INIT"), ImmutableList.copyOf(states.get(handlerHashCode))); // Sleep for 3 seconds for the thread going IDLE, gets terminated and cleanup TimeUnit.SECONDS.sleep(3); states = getStates(serviceManager); // Size of states keys should be two, since the old instance must get destroy and there is a new // one created to handle the getStates request. Assert.assertEquals(2, states.keySet().size()); // For the state changes for the old handler, it should have INIT, DESTROY Assert.assertEquals(ImmutableList.of("INIT", "DESTROY"), ImmutableList.copyOf(states.get(handlerHashCode))); // For the state changes for the new handler, it should be INIT for (int key : states.keys()) { if (key != handlerHashCode) { Assert.assertEquals(ImmutableList.of("INIT"), ImmutableList.copyOf(states.get(key))); } } } finally { // Reset the http server properties to speed up test System.clearProperty(ServiceHttpServer.THREAD_POOL_SIZE); System.clearProperty(ServiceHttpServer.THREAD_KEEP_ALIVE_SECONDS); System.clearProperty(ServiceHttpServer.HANDLER_CLEANUP_PERIOD_MILLIS); } } @Test public void testLifecycleWithGC() throws Exception { // Set the http server properties to speed up test System.setProperty(ServiceHttpServer.THREAD_POOL_SIZE, "1"); System.setProperty(ServiceHttpServer.THREAD_KEEP_ALIVE_SECONDS, "1"); System.setProperty(ServiceHttpServer.HANDLER_CLEANUP_PERIOD_MILLIS, "100"); try { ApplicationManager appManager = deployApplication(ServiceLifecycleApp.class); final ServiceManager serviceManager = appManager.getServiceManager("test").start(); // Make 5 consecutive calls, there should be one handler instance being created, // since there is only one handler thread. Multimap<Integer, String> states = null; for (int i = 0; i < 5; i++) { states = getStates(serviceManager); // There should only be one instance created Assert.assertEquals(1, states.size()); // For the instance, there should only be INIT state. Assert.assertEquals(ImmutableList.of("INIT"), ImmutableList.copyOf(states.get(states.keySet().iterator().next()))); } // Capture the current state final Multimap<Integer, String> lastStates = states; // TTL for the thread is 1 second, hence sleep for 2 second to make sure the thread is gone TimeUnit.SECONDS.sleep(2); Tasks.waitFor(true, new Callable<Boolean>() { @Override public Boolean call() throws Exception { // Force a gc to have weak references cleanup System.gc(); Multimap<Integer, String> newStates = getStates(serviceManager); // Should expect size be 3. An INIT and a DESTROY from the collected handler // and an INIT for the new handler that just handle the getState call if (newStates.size() != 3) { return false; } // A INIT and a DESTROY is expected for the old handler return ImmutableList.of("INIT", "DESTROY") .equals(ImmutableList.copyOf(newStates.get(lastStates.keySet().iterator().next()))); } }, 10, TimeUnit.SECONDS, 100, TimeUnit.MILLISECONDS); } finally { // Reset the http server properties to speed up test System.clearProperty(ServiceHttpServer.THREAD_POOL_SIZE); System.clearProperty(ServiceHttpServer.THREAD_KEEP_ALIVE_SECONDS); System.clearProperty(ServiceHttpServer.HANDLER_CLEANUP_PERIOD_MILLIS); } } @Test public void testContentConsumerLifecycle() throws Exception { // Set to have one thread only for testing context capture and release System.setProperty(ServiceHttpServer.THREAD_POOL_SIZE, "1"); try { ApplicationManager appManager = deployApplication(ServiceLifecycleApp.class); final ServiceManager serviceManager = appManager.getServiceManager("test").start(); CountDownLatch uploadLatch = new CountDownLatch(1); // Create five concurrent upload List<ListenableFuture<Integer>> completions = new ArrayList<>(); for (int i = 0; i < 5; i++) { completions.add(slowUpload(serviceManager, "PUT", "upload", uploadLatch)); } // Get the states, there should be six handler instances initialized. // Five for the in-progress upload, one for the getStates call Tasks.waitFor(6, new Callable<Integer>() { @Override public Integer call() throws Exception { return getStates(serviceManager).size(); } }, 5, TimeUnit.SECONDS, 100, TimeUnit.MILLISECONDS); // Finish the upload uploadLatch.countDown(); Futures.successfulAsList(completions).get(10, TimeUnit.SECONDS); // Verify the result for (ListenableFuture<Integer> future : completions) { Assert.assertEquals(200, future.get().intValue()); } // Get the states, there should still be six handler instances initialized. final Multimap<Integer, String> states = getStates(serviceManager); Assert.assertEquals(6, states.size()); // Do another round of six concurrent upload. It should reuse all of the existing six contexts completions.clear(); uploadLatch = new CountDownLatch(1); for (int i = 0; i < 6; i++) { completions.add(slowUpload(serviceManager, "PUT", "upload", uploadLatch)); } // Get the states, there should be seven handler instances initialized. // Six for the in-progress upload, one for the getStates call // Out of the 7 states, six of them should be the same as the old one Tasks.waitFor(true, new Callable<Boolean>() { @Override public Boolean call() throws Exception { Multimap<Integer, String> newStates = getStates(serviceManager); if (newStates.size() != 7) { return false; } for (Map.Entry<Integer, String> entry : states.entries()) { if (!newStates.containsEntry(entry.getKey(), entry.getValue())) { return false; } } return true; } }, 5, TimeUnit.SECONDS, 100, TimeUnit.MILLISECONDS); // Complete the upload uploadLatch.countDown(); Futures.successfulAsList(completions).get(10, TimeUnit.SECONDS); // Verify the result for (ListenableFuture<Integer> future : completions) { Assert.assertEquals(200, future.get().intValue()); } // Query the queue size metrics. Expect the maximum be 6. // This is because only the six from the concurrent upload will get captured added back to the queue, // while the one created for the getState() call will be stated in the thread cache, but not in the queue. Tasks.waitFor(6L, new Callable<Long>() { @Override public Long call() throws Exception { Map<String, String> context = ImmutableMap.of( Constants.Metrics.Tag.NAMESPACE, Id.Namespace.DEFAULT.getId(), Constants.Metrics.Tag.APP, ServiceLifecycleApp.class.getSimpleName(), Constants.Metrics.Tag.SERVICE, "test"); MetricDataQuery metricQuery = new MetricDataQuery(0, Integer.MAX_VALUE, Integer.MAX_VALUE, "system.context.pool.size", AggregationFunction.MAX, context, ImmutableList.<String>of()); Iterator<MetricTimeSeries> result = getMetricsManager().query(metricQuery).iterator(); return result.hasNext() ? result.next().getTimeValues().get(0).getValue() : 0L; } }, 5, TimeUnit.SECONDS, 100, TimeUnit.MILLISECONDS); } finally { System.clearProperty(ServiceHttpServer.THREAD_POOL_SIZE); } } @Test public void testContentProducerLifecycle() throws Exception { // Set to have one thread only for testing context capture and release System.setProperty(ServiceHttpServer.THREAD_POOL_SIZE, "1"); try { ApplicationManager appManager = deployApplication(ServiceLifecycleApp.class); final ServiceManager serviceManager = appManager.getServiceManager("test").start(); final DataSetManager<KeyValueTable> datasetManager = getDataset(ServiceLifecycleApp.HANDLER_TABLE_NAME); // Clean up the dataset first to avoid being affected by other tests datasetManager.get().delete(Bytes.toBytes("called")); datasetManager.get().delete(Bytes.toBytes("completed")); datasetManager.flush(); // Starts 5 concurrent downloads List<ListenableFuture<String>> completions = new ArrayList<>(); for (int i = 0; i < 5; i++) { completions.add(download(serviceManager)); } // Make sure all producers has produced something Tasks.waitFor(true, new Callable<Boolean>() { @Override public Boolean call() throws Exception { byte[] value = datasetManager.get().read("called"); datasetManager.flush(); if (value == null || value.length != Bytes.SIZEOF_LONG) { return false; } return Bytes.toLong(value) > 5; } }, 10L , TimeUnit.SECONDS, 100, TimeUnit.MILLISECONDS); // Get the states, there should be 6 handler instances instantiated, 5 the downloads, one for getState. Multimap<Integer, String> states = getStates(serviceManager); Assert.assertEquals(6, states.size()); // Set the complete flag in the dataset datasetManager.get().write("completed", Bytes.toBytes(true)); datasetManager.flush(); // Wait for download to complete Futures.allAsList(completions).get(10L, TimeUnit.SECONDS); // Get the states again, it should still be 6 same instances Assert.assertEquals(states, getStates(serviceManager)); } finally { System.clearProperty(ServiceHttpServer.THREAD_POOL_SIZE); } } @Test public void testContentConsumerProducerLifecycle() throws Exception { // Set to have one thread only for testing context capture and release System.setProperty(ServiceHttpServer.THREAD_POOL_SIZE, "1"); try { ApplicationManager appManager = deployApplication(ServiceLifecycleApp.class); final ServiceManager serviceManager = appManager.getServiceManager("test").start(); final DataSetManager<KeyValueTable> datasetManager = getDataset(ServiceLifecycleApp.HANDLER_TABLE_NAME); // Clean up the dataset first to avoid being affected by other tests datasetManager.get().delete(Bytes.toBytes("called")); datasetManager.get().delete(Bytes.toBytes("completed")); datasetManager.flush(); CountDownLatch uploadLatch = new CountDownLatch(1); // Create five concurrent upload List<ListenableFuture<Integer>> completions = new ArrayList<>(); for (int i = 0; i < 5; i++) { completions.add(slowUpload(serviceManager, "POST", "uploadDownload", uploadLatch)); } // Get the states, there should be six handler instances initialized. // Five for the in-progress upload, one for the getStates call Tasks.waitFor(6, new Callable<Integer>() { @Override public Integer call() throws Exception { return getStates(serviceManager).size(); } }, 5, TimeUnit.SECONDS, 100, TimeUnit.MILLISECONDS); // Complete the upload uploadLatch.countDown(); // Make sure the download through content producer has started Tasks.waitFor(true, new Callable<Boolean>() { @Override public Boolean call() throws Exception { byte[] value = datasetManager.get().read("called"); datasetManager.flush(); if (value == null || value.length != Bytes.SIZEOF_LONG) { return false; } return Bytes.toLong(value) > 5; } }, 10L , TimeUnit.SECONDS, 100, TimeUnit.MILLISECONDS); // Get the states, there should still be six handler instances since the ContentConsumer should // be passing it's captured context to the ContentProducer without creating new one. Multimap<Integer, String> states = getStates(serviceManager); Assert.assertEquals(6, states.size()); // Set the complete flag in the dataset datasetManager.get().write("completed", Bytes.toBytes(true)); datasetManager.flush(); // Wait for completion Futures.successfulAsList(completions).get(10, TimeUnit.SECONDS); // Verify the upload result for (ListenableFuture<Integer> future : completions) { Assert.assertEquals(200, future.get().intValue()); } // Get the states again, it should still be 6 same instances Assert.assertEquals(states, getStates(serviceManager)); } finally { System.clearProperty(ServiceHttpServer.THREAD_POOL_SIZE); } } @Test public void testInvalidResponder() throws Exception { ApplicationManager appManager = deployApplication(ServiceLifecycleApp.class); final ServiceManager serviceManager = appManager.getServiceManager("test").start(); CountDownLatch uploadLatch = new CountDownLatch(1); ListenableFuture<Integer> completion = slowUpload(serviceManager, "PUT", "invalid", uploadLatch); uploadLatch.countDown(); Assert.assertEquals(500, completion.get().intValue()); } @Test public void testInvalidContentProducer() throws Exception { ApplicationManager appManager = deployApplication(ServiceLifecycleApp.class); final ServiceManager serviceManager = appManager.getServiceManager("test").start(); URL serviceURL = serviceManager.getServiceURL(10, TimeUnit.SECONDS); URL url = serviceURL.toURI().resolve("invalid?methods=getContentLength").toURL(); HttpURLConnection urlConn = (HttpURLConnection) url.openConnection(); try { Assert.assertEquals(500, urlConn.getResponseCode()); } finally { urlConn.disconnect(); } // Exception from both nextChunk and onError url = serviceURL.toURI().resolve("invalid?methods=nextChunk&methods=onError").toURL(); urlConn = (HttpURLConnection) url.openConnection(); try { // 200 will be the status code Assert.assertEquals(200, urlConn.getResponseCode()); // Expect IOException when trying to read since the server closed the connection try { ByteStreams.toByteArray(urlConn.getInputStream()); Assert.fail("Expected IOException"); } catch (IOException e) { // expected } } finally { urlConn.disconnect(); } // Exception from both onFinish url = serviceURL.toURI().resolve("invalid?methods=onFinish").toURL(); urlConn = (HttpURLConnection) url.openConnection(); try { // 200 will be the status code. Since the response is completed, from the client perspective, there is no error. Assert.assertEquals(200, urlConn.getResponseCode()); Assert.assertEquals("0123456789", new String(ByteStreams.toByteArray(urlConn.getInputStream()), "UTF-8")); } finally { urlConn.disconnect(); } } /** * Returns the handler state change as a Multimap. The key is the hashcode of the handler instance, * the value is a list of state changes for that handler instance. */ private Multimap<Integer, String> getStates(ServiceManager serviceManager) throws Exception { URL url = serviceManager.getServiceURL(10, TimeUnit.SECONDS).toURI().resolve("states").toURL(); Multimap<Integer, String> result = LinkedListMultimap.create(); try (InputStream is = url.openConnection().getInputStream()) { List<ImmutablePair<Integer, String>> states = GSON.fromJson(new InputStreamReader(is, Charsets.UTF_8), STATES_TYPE); for (ImmutablePair<Integer, String> pair : states) { result.put(pair.getFirst(), pair.getSecond()); } } return result; } private ListenableFuture<Integer> slowUpload(final ServiceManager serviceManager, final String method, final String endpoint, final CountDownLatch latch) throws Exception { final SettableFuture<Integer> completion = SettableFuture.create(); Thread t = new Thread() { @Override public void run() { try { URL url = serviceManager.getServiceURL(10, TimeUnit.SECONDS).toURI().resolve(endpoint).toURL(); HttpURLConnection urlConn = (HttpURLConnection) url.openConnection(); try { urlConn.setChunkedStreamingMode(5); urlConn.setDoOutput(true); urlConn.setRequestMethod(method); try (OutputStream os = urlConn.getOutputStream()) { // Write some chunks os.write("Testing".getBytes(Charsets.UTF_8)); os.flush(); latch.await(); } int sc = urlConn.getResponseCode(); if (sc == 200) { ByteStreams.toByteArray(urlConn.getInputStream()); } else { ByteStreams.toByteArray(urlConn.getErrorStream()); } completion.set(sc); } finally { urlConn.disconnect(); } } catch (Exception e) { throw Throwables.propagate(e); } } }; t.start(); return completion; } private ListenableFuture<String> download(final ServiceManager serviceManager) { final SettableFuture<String> completion = SettableFuture.create(); Thread t = new Thread() { @Override public void run() { try { URL url = serviceManager.getServiceURL(10, TimeUnit.SECONDS).toURI().resolve("download").toURL(); HttpURLConnection urlConn = (HttpURLConnection) url.openConnection(); try { completion.set(new String(ByteStreams.toByteArray(urlConn.getInputStream()), Charsets.UTF_8)); } finally { urlConn.disconnect(); } } catch (Exception e) { throw Throwables.propagate(e); } } }; t.start(); return completion; } }