This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/commons-jcs.git
commit 85b906cf4611bc4837c05d69eabfe406a414b047 Author: Thomas Vandahl <[email protected]> AuthorDate: Wed Aug 26 13:12:42 2026 +0200 Unify the handling of thread pools and their lifecycle. Fixes JCS-248 --- .../commons/jcs4/engine/CacheEventQueue.java | 10 +- .../commons/jcs4/engine/PooledCacheEventQueue.java | 31 +-- .../engine/control/event/ElementEventQueue.java | 9 +- .../jcs4/utils/discovery/UDPDiscoveryReceiver.java | 5 +- .../jcs4/utils/threadpool/PoolConfiguration.java | 29 ++- .../jcs4/utils/threadpool/ThreadPoolManager.java | 232 ++++++++++++++++----- .../control/event/ElementEventQueueUnitTest.java | 87 ++++++++ 7 files changed, 312 insertions(+), 91 deletions(-) diff --git a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/CacheEventQueue.java b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/CacheEventQueue.java index 0dc68083..e9a75bb9 100644 --- a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/CacheEventQueue.java +++ b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/CacheEventQueue.java @@ -56,22 +56,20 @@ public class CacheEventQueue<K, V> public CacheEventQueue( final ICacheListener<K, V> listener, final long listenerId, final String cacheName, final int maxFailure, final int waitBeforeRetry ) { - super( listener, listenerId, cacheName, maxFailure, waitBeforeRetry, null ); + super( listener, listenerId, cacheName, maxFailure, waitBeforeRetry, "CacheEventQueue.QProcessor-" + cacheName); } /** * Create the thread pool. * - * @param threadPoolName * @since 3.1 */ @Override - protected ExecutorService createPool(final String threadPoolName) + protected ExecutorService createPool() { // create a default pool with one worker thread to mimic the SINGLE queue behavior - return ThreadPoolManager.getInstance().createPool( - new PoolConfiguration(false, 0, 1, 1, getWaitToDie(), WhenBlockedPolicy.RUN, 1), - "CacheEventQueue.QProcessor-" + getCacheName()); + return ThreadPoolManager.getInstance().getExecutorService(poolName, + new PoolConfiguration(false, 0, 1, 1, getWaitToDie(), WhenBlockedPolicy.RUN, 1)); } /** diff --git a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/PooledCacheEventQueue.java b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/PooledCacheEventQueue.java index 1f8178e8..b27914af 100644 --- a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/PooledCacheEventQueue.java +++ b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/PooledCacheEventQueue.java @@ -24,7 +24,6 @@ import java.time.Duration; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; import org.apache.commons.jcs4.engine.behavior.ICacheListener; import org.apache.commons.jcs4.engine.stats.Stats; @@ -51,6 +50,9 @@ public class PooledCacheEventQueue<K, V> /** The Thread Pool to execute events with. */ protected ExecutorService pool; + /** The Thread Pool name in ThreadPoolManager. */ + protected String poolName; + /** The Thread Pool queue */ protected BlockingQueue<Runnable> queue; @@ -73,14 +75,12 @@ public class PooledCacheEventQueue<K, V> /** * Create the thread pool. * - * @param threadPoolName * @since 3.1 */ - protected ExecutorService createPool(final String threadPoolName) + protected ExecutorService createPool() { // this will share the same pool with other event queues by default. - return ThreadPoolManager.getInstance().getExecutorService( - threadPoolName == null ? "cache_event_queue" : threadPoolName ); + return ThreadPoolManager.getInstance().getExecutorService(poolName); } /** @@ -94,23 +94,7 @@ public class PooledCacheEventQueue<K, V> if ( isWorking() ) { setWorking(false); - pool.shutdown(); - - if (wait.toSeconds() > 0) - { - try - { - if (!pool.awaitTermination(wait.toSeconds(), TimeUnit.SECONDS)) - { - log.info( "No longer waiting for event queue to finish: {0}", - this::getStatistics); - } - } - catch (final InterruptedException e) - { - // ignore - } - } + ThreadPoolManager.getInstance().disposeExecutorService(poolName, wait); log.info( "Cache event queue destroyed: {0}", this ); } } @@ -160,7 +144,8 @@ public class PooledCacheEventQueue<K, V> { super.initialize(listener, listenerId, cacheName, maxFailure, waitBeforeRetry); - pool = createPool(threadPoolName); + poolName = threadPoolName == null ? "cache_event_queue" : threadPoolName; + pool = createPool(); if (pool instanceof ThreadPoolExecutor tpe) { diff --git a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/control/event/ElementEventQueue.java b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/control/event/ElementEventQueue.java index 0a0948a9..cffc420f 100644 --- a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/control/event/ElementEventQueue.java +++ b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/engine/control/event/ElementEventQueue.java @@ -38,7 +38,7 @@ import org.apache.commons.jcs4.utils.threadpool.ThreadPoolManager; public class ElementEventQueue implements IElementEventQueue { - private static final String THREAD_PREFIX = "JCS-ElementEventQueue-"; + protected static final String POOL_NAME = "ElementEventQueue"; /** The logger */ private static final Log log = Log.getLog( ElementEventQueue.class ); @@ -54,8 +54,8 @@ public class ElementEventQueue */ public ElementEventQueue() { - queueProcessor = ThreadPoolManager.getInstance().createPool( - new PoolConfiguration(false, 0, 1, 1, Duration.ZERO, WhenBlockedPolicy.RUN, 1), THREAD_PREFIX); + queueProcessor = ThreadPoolManager.getInstance().getExecutorService(POOL_NAME, + new PoolConfiguration(false, 0, 1, 1, Duration.ZERO, WhenBlockedPolicy.RUN, 1)); log.debug( "Constructed: {0}", this ); } @@ -91,8 +91,7 @@ public class ElementEventQueue { if (destroyed.compareAndSet(false, true)) { - // Pool will be shut down by the ThreadPoolManager - // queueProcessor.shutdownNow(); + ThreadPoolManager.getInstance().disposeExecutorService(POOL_NAME); log.info( "Element event queue destroyed: {0}", this ); } } diff --git a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/discovery/UDPDiscoveryReceiver.java b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/discovery/UDPDiscoveryReceiver.java index 51d41191..eb217107 100644 --- a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/discovery/UDPDiscoveryReceiver.java +++ b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/discovery/UDPDiscoveryReceiver.java @@ -105,10 +105,9 @@ public class UDPDiscoveryReceiver setService(service); // create a small thread pool to handle a barrage - this.pooledExecutor = ThreadPoolManager.getInstance().createPool( + this.pooledExecutor = ThreadPoolManager.getInstance().getExecutorService("UDPDiscoveryReceiver", new PoolConfiguration(false, 0, maxPoolSize, maxPoolSize, Duration.ZERO, - WhenBlockedPolicy.DISCARDOLDEST, maxPoolSize), - "JCS-UDPDiscoveryReceiver-", Thread.MIN_PRIORITY); + WhenBlockedPolicy.DISCARDOLDEST, maxPoolSize, Thread.MIN_PRIORITY)); log.info( "Constructing listener, [{0}:{1}]", multicastAddress, multicastPort ); createSocket( multicastInterfaceString, multicastAddress, multicastPort ); diff --git a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/threadpool/PoolConfiguration.java b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/threadpool/PoolConfiguration.java index aa88096b..c022080b 100644 --- a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/threadpool/PoolConfiguration.java +++ b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/threadpool/PoolConfiguration.java @@ -47,7 +47,10 @@ public record PoolConfiguration( WhenBlockedPolicy whenBlockedPolicy, /** The number of threads to create on startup */ - int startUpSize + int startUpSize, + + /** The thread priority */ + int threadPriority ) implements Cloneable { public enum WhenBlockedPolicy @@ -88,12 +91,15 @@ public record PoolConfiguration( /** Default startup size */ private static final int DEFAULT_STARTUP_SIZE = DEFAULT_MINIMUM_POOL_SIZE; + /** Default thread priority */ + private static final int DEFAULT_THREAD_PRIORITY = Thread.NORM_PRIORITY; + /** * Default */ private static PoolConfiguration DEFAULT = new PoolConfiguration(DEFAULT_USE_BOUNDARY, DEFAULT_BOUNDARY_SIZE, DEFAULT_MAXIMUM_POOL_SIZE, DEFAULT_MINIMUM_POOL_SIZE, - DEFAULT_KEEPALIVE_TIME, DEFAULT_WHEN_BLOCKED_POLICY, DEFAULT_STARTUP_SIZE); + DEFAULT_KEEPALIVE_TIME, DEFAULT_WHEN_BLOCKED_POLICY, DEFAULT_STARTUP_SIZE, DEFAULT_THREAD_PRIORITY); /** * @return An object containing the default settings @@ -103,6 +109,22 @@ public record PoolConfiguration( return DEFAULT; } + /** + * Convenience constructor + * + * @param useBoundary Should we bound the queue + * @param boundarySize If the queue is bounded, how big can it get + * @param maximumPoolSize Only has meaning if a boundary is used + * @param minimumPoolSize the exact number that will be used in a boundless queue + * @param keepAliveTime How long idle threads above the minimum should be kept alive + * @param whenBlockedPolicy Should be ABORT, BLOCK, RUN, WAIT, DISCARDOLDEST + * @param startUpSize The number of threads to create on startup + */ + public PoolConfiguration(boolean useBoundary, int boundarySize, int maximumPoolSize, int minimumPoolSize, Duration keepAliveTime, + WhenBlockedPolicy whenBlockedPolicy, int startUpSize) + { + this(useBoundary, boundarySize, maximumPoolSize, minimumPoolSize, keepAliveTime, whenBlockedPolicy, startUpSize, DEFAULT_THREAD_PRIORITY); + } /** * To string for debugging purposes. @@ -118,7 +140,8 @@ public record PoolConfiguration( buf.append("minimumPoolSize = [").append(minimumPoolSize()).append("] "); buf.append("keepAliveTime = [").append(keepAliveTime()).append("] "); buf.append("whenBlockedPolicy = [").append(whenBlockedPolicy()).append("] "); - buf.append("startUpSize = [").append(startUpSize()).append("]" ); + buf.append("startUpSize = [").append(startUpSize()).append("] " ); + buf.append("threadPriority = [").append(threadPriority()).append("]" ); return buf.toString(); } } diff --git a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/threadpool/ThreadPoolManager.java b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/threadpool/ThreadPoolManager.java index ddbf7aeb..b9e807ea 100644 --- a/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/threadpool/ThreadPoolManager.java +++ b/commons-jcs4-core/src/main/java/org/apache/commons/jcs4/utils/threadpool/ThreadPoolManager.java @@ -1,5 +1,7 @@ package org.apache.commons.jcs4.utils.threadpool; +import java.time.Duration; + /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -34,6 +36,24 @@ import java.util.concurrent.TimeUnit; import org.apache.commons.jcs4.log.Log; import org.apache.commons.jcs4.utils.config.ConfigurationBuilder; +/* + * 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 + * + * https://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. + */ /** * This manages threadpools for an application @@ -68,6 +88,9 @@ public class ThreadPoolManager /** The logger */ private static final Log log = Log.getLog( ThreadPoolManager.class ); + /** The common prefix for all thread names managed by the ThreadPoolManager */ + public static final String JCS_THREAD_POOL_MANAGER_PREFIX = "JCS-ThreadPoolManager-"; + /** The root property name */ private static final String PROP_NAME_ROOT = "thread_pool"; @@ -81,46 +104,154 @@ public class ThreadPoolManager private static final String DEFAULT_PROP_NAME_SCHEDULER_ROOT = "scheduler_pool.default"; /** - * You can specify the properties to be used to configure the thread pool. Setting this post - * initialization will have no effect. - */ - private static volatile Properties props; + * You can specify the properties to be used to configure the thread pool. Setting this post + * initialization will have no effect. + */ + private static volatile Properties props; - /** - * Dispose of the instance of the ThreadPoolManger and shut down all thread pools - */ -public static void dispose() -{ - for ( final Iterator<Map.Entry<String, ExecutorService>> i = - getInstance().pools.entrySet().iterator(); i.hasNext(); ) + /** + * Dispose of the instance of the ThreadPoolManger and shut down all thread pools + */ + public static void dispose() { - final Map.Entry<String, ExecutorService> entry = i.next(); - try + for ( final Iterator<Map.Entry<String, ExecutorService>> i = + getInstance().pools.entrySet().iterator(); i.hasNext(); ) { - entry.getValue().shutdownNow(); + final Map.Entry<String, ExecutorService> entry = i.next(); + try + { + entry.getValue().shutdownNow(); + } + catch (final Throwable t) + { + log.warn("Failed to close pool {0}", entry.getKey(), t); + } + i.remove(); } - catch (final Throwable t) + + for ( final Iterator<Map.Entry<String, ScheduledExecutorService>> i = + getInstance().schedulerPools.entrySet().iterator(); i.hasNext(); ) { - log.warn("Failed to close pool {0}", entry.getKey(), t); + final Map.Entry<String, ScheduledExecutorService> entry = i.next(); + try + { + entry.getValue().shutdownNow(); + } + catch (final Throwable t) + { + log.warn("Failed to close pool {0}", entry.getKey(), t); + } + i.remove(); } - i.remove(); } - for ( final Iterator<Map.Entry<String, ScheduledExecutorService>> i = - getInstance().schedulerPools.entrySet().iterator(); i.hasNext(); ) + /** + * Dispose of a thread pool + * + * @param poolName the name of the pool + */ + public void disposeExecutorService(String poolName) + { + disposeExecutorService(poolName, Duration.ZERO); + } + + /** + * Dispose of a thread pool + * + * @param poolName the name of the pool + * @param wait Duration to wait for termination + */ + public void disposeExecutorService(String poolName, Duration wait) { - final Map.Entry<String, ScheduledExecutorService> entry = i.next(); - try + ExecutorService pool = pools.remove(poolName); + if (pool == null) { - entry.getValue().shutdownNow(); + log.warn("Failed to close non-existing pool {0}", poolName); } - catch (final Throwable t) + else { - log.warn("Failed to close pool {0}", entry.getKey(), t); + try + { + if (wait == null || wait.isZero()) + { + pool.shutdownNow(); + } + else + { + pool.shutdown(); + try + { + if (!pool.awaitTermination(wait.toSeconds(), TimeUnit.SECONDS)) + { + log.info( "No longer waiting for pool {0} to terminate", poolName); + } + } + catch (final InterruptedException e) + { + // ignore + } + } + } + catch (final Throwable t) + { + log.warn("Failed to close pool {0}", poolName, t); + } + } + } + + /** + * Dispose of a scheduler thread pool + * + * @param poolName the name of the pool + */ + public void disposeSchedulerPool(String poolName) + { + disposeSchedulerPool(poolName, Duration.ZERO); + } + + /** + * Dispose of a scheduler thread pool + * + * @param poolName the name of the pool + * @param wait Duration to wait for termination + */ + public void disposeSchedulerPool(String poolName, Duration wait) + { + ExecutorService pool = schedulerPools.remove(poolName); + if (pool == null) + { + log.warn("Failed to close non-existing pool {0}", poolName); + } + else + { + try + { + if (wait == null || wait.isZero()) + { + pool.shutdownNow(); + } + else + { + pool.shutdown(); + try + { + if (!pool.awaitTermination(wait.toSeconds(), TimeUnit.SECONDS)) + { + log.info( "No longer waiting for pool {0} to terminate", poolName); + } + } + catch (final InterruptedException e) + { + // ignore + } + } + } + catch (final Throwable t) + { + log.warn("Failed to close pool {0}", poolName, t); + } } - i.remove(); } -} /** * Returns a configured instance of the ThreadPoolManger To specify a configuration file or @@ -210,20 +341,7 @@ public static void dispose() * @param threadNamePrefix prefix for the thread names of the pool * @return A ThreadPool wrapper */ - public ExecutorService createPool( final PoolConfiguration config, final String threadNamePrefix) - { - return createPool(config, threadNamePrefix, Thread.NORM_PRIORITY); - } - - /** - * Creates a pool based on the configuration info. - * - * @param config The pool configuration - * @param threadNamePrefix prefix for the thread names of the pool - * @param threadPriority The priority of the created threads - * @return A ThreadPool wrapper - */ - public ExecutorService createPool( final PoolConfiguration config, final String threadNamePrefix, final int threadPriority ) + private ExecutorService createPool( final PoolConfiguration config, final String threadNamePrefix) { BlockingQueue<Runnable> queue = null; if ( config.useBoundary() ) @@ -243,7 +361,7 @@ public static void dispose() config.keepAliveTime().toMillis(), TimeUnit.MILLISECONDS, queue, - new DaemonThreadFactory(threadNamePrefix, threadPriority)); + new DaemonThreadFactory(threadNamePrefix, config.threadPriority())); // when blocked policy switch (config.whenBlockedPolicy()) @@ -275,15 +393,13 @@ public static void dispose() * * @param config The pool configuration * @param threadNamePrefix prefix for the thread names of the pool - * @param threadPriority The priority of the created threads * @return A ScheduledExecutorService */ - public ScheduledExecutorService createSchedulerPool( final PoolConfiguration config, final String threadNamePrefix, final int threadPriority ) + private ScheduledExecutorService createSchedulerPool(final PoolConfiguration config, final String threadNamePrefix) { - return Executors.newScheduledThreadPool( config.maximumPoolSize(), - new DaemonThreadFactory(threadNamePrefix, threadPriority)); + new DaemonThreadFactory(threadNamePrefix, config.threadPriority())); } /** @@ -297,11 +413,25 @@ public static void dispose() */ public ExecutorService getExecutorService( final String name ) { - return pools.computeIfAbsent(name, key -> { - log.debug( "Creating pool for name [{0}]", key ); - final PoolConfiguration config = loadConfig( PROP_NAME_ROOT + "." + key, defaultConfig ); - return createPool( config, "JCS-ThreadPoolManager-" + key + "-" ); - }); + return getExecutorService(name, loadConfig( PROP_NAME_ROOT + "." + name, defaultConfig )); + } + + /** + * Returns an executor service by name. If a service by this name does not exist in the configuration file or + * properties, one will be created using the default values. + * <p> + * Services are lazily created. + * + * @param name + * @param config The pool configuration + * @return The executor service configured for the name. + */ + public ExecutorService getExecutorService(final String name, final PoolConfiguration config) + { + return pools.computeIfAbsent(name, key -> { + log.debug("Creating pool for name [{0}]", key); + return createPool(config, JCS_THREAD_POOL_MANAGER_PREFIX + key + "-"); + }); } /** @@ -329,7 +459,7 @@ public static void dispose() log.debug( "Creating scheduler pool for name [{0}]", key ); final PoolConfiguration config = loadConfig( PROP_NAME_SCHEDULER_ROOT + "." + key, defaultSchedulerConfig ); - return createSchedulerPool( config, "JCS-ThreadPoolManager-" + key + "-", Thread.NORM_PRIORITY ); + return createSchedulerPool( config, JCS_THREAD_POOL_MANAGER_PREFIX + key + "-"); }); } } diff --git a/commons-jcs4-core/src/test/java/org/apache/commons/jcs4/engine/control/event/ElementEventQueueUnitTest.java b/commons-jcs4-core/src/test/java/org/apache/commons/jcs4/engine/control/event/ElementEventQueueUnitTest.java new file mode 100644 index 00000000..96ca2d40 --- /dev/null +++ b/commons-jcs4-core/src/test/java/org/apache/commons/jcs4/engine/control/event/ElementEventQueueUnitTest.java @@ -0,0 +1,87 @@ +package org.apache.commons.jcs4.engine.control.event; + +/* + * 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 + * + * https://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. + */ + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.HashSet; +import java.util.Set; + +import org.apache.commons.jcs4.utils.threadpool.ThreadPoolManager; +import org.junit.jupiter.api.Test; + +/** Tests the lifecycle of the element-event worker owned by the queue. */ +class ElementEventQueueUnitTest +{ + private static final String THREAD_PREFIX = ThreadPoolManager.JCS_THREAD_POOL_MANAGER_PREFIX + ElementEventQueue.POOL_NAME; + + @Test + void testDisposeStopsOwnedWorkerThread() + throws InterruptedException + { + final Set<Long> threadsBefore = eventQueueThreadIds(); + final ElementEventQueue queue = new ElementEventQueue(); + final Thread worker = waitForNewWorker( threadsBefore ); + + assertNotNull( worker, "The element-event queue did not start its worker" ); + + queue.dispose(); + worker.join( 2000 ); + + assertFalse( worker.isAlive(), "The element-event worker is still alive after dispose" ); + + // Disposal is a lifecycle operation and must be safe when invoked more than once. + queue.dispose(); + } + + private static Set<Long> eventQueueThreadIds() + { + final Set<Long> result = new HashSet<>(); + for ( final Thread thread : Thread.getAllStackTraces().keySet() ) + { + if (thread.getName().startsWith(THREAD_PREFIX)) + { + result.add( thread.getId() ); + } + } + return result; + } + + private static Thread waitForNewWorker( final Set<Long> threadsBefore ) + throws InterruptedException + { + final long deadline = System.currentTimeMillis() + 2000; + do + { + for ( final Thread thread : Thread.getAllStackTraces().keySet() ) + { + if (thread.getName().startsWith(THREAD_PREFIX) && !threadsBefore.contains(thread.getId())) + { + return thread; + } + } + Thread.sleep( 10 ); + } + while (System.currentTimeMillis() < deadline); + + return null; + } +} \ No newline at end of file
