diff --git a/src/main/java/org/springframework/retry/RetryContext.java b/src/main/java/org/springframework/retry/RetryContext.java index 77efefa..5240baf 100644 --- a/src/main/java/org/springframework/retry/RetryContext.java +++ b/src/main/java/org/springframework/retry/RetryContext.java @@ -32,7 +32,27 @@ public interface RetryContext extends AttributeAccessor { * for instance in a retry listener, to accumulate data about the performance of a * retry. */ - String STATS_NAME = "stats.name"; + String NAME = "context.name"; + + /** + * Retry context attribute name for state key. Can be used to identify a stateful retry from its context. + */ + String STATE_KEY = "context.state"; + + /** + * Retry context attribute that is non-null (and true) if the context has been closed. + */ + String CLOSED = "context.closed"; + + /** + * Retry context attribute that is non-null (and true) if the recovery path was taken. + */ + String RECOVERED = "context.recovered"; + + /** + * Retry context attribute that is non-null (and true) if the retry was exhausted. + */ + String EXHAUSTED = "context.exhausted"; /** * Signal to the framework that no more attempts should be made to try or retry the diff --git a/src/main/java/org/springframework/retry/RetryStatistics.java b/src/main/java/org/springframework/retry/RetryStatistics.java index b7b0409..a86e1b8 100644 --- a/src/main/java/org/springframework/retry/RetryStatistics.java +++ b/src/main/java/org/springframework/retry/RetryStatistics.java @@ -26,7 +26,7 @@ package org.springframework.retry; public interface RetryStatistics { /** - * @return the number of completed retry attempts (successful or not). + * @return the number of completed successful retry attempts. */ int getCompleteCount(); @@ -54,6 +54,13 @@ public interface RetryStatistics { */ int getAbortCount(); + /** + * Get the number of times a recovery callback was applied. + * + * @return the number of recovered attempts. + */ + int getRecoveryCount(); + /** * Get an identifier for the retry block for reporting purposes. * diff --git a/src/main/java/org/springframework/retry/interceptor/RetryOperationsInterceptor.java b/src/main/java/org/springframework/retry/interceptor/RetryOperationsInterceptor.java index 70a9071..226c7b1 100644 --- a/src/main/java/org/springframework/retry/interceptor/RetryOperationsInterceptor.java +++ b/src/main/java/org/springframework/retry/interceptor/RetryOperationsInterceptor.java @@ -77,7 +77,7 @@ public class RetryOperationsInterceptor implements MethodInterceptor { public Object doWithRetry(RetryContext context) throws Exception { - context.setAttribute(RetryContext.STATS_NAME, label); + context.setAttribute(RetryContext.NAME, label); /* * If we don't copy the invocation carefully it won't keep a reference to diff --git a/src/main/java/org/springframework/retry/interceptor/StatefulRetryOperationsInterceptor.java b/src/main/java/org/springframework/retry/interceptor/StatefulRetryOperationsInterceptor.java index 35445ee..9d1a851 100644 --- a/src/main/java/org/springframework/retry/interceptor/StatefulRetryOperationsInterceptor.java +++ b/src/main/java/org/springframework/retry/interceptor/StatefulRetryOperationsInterceptor.java @@ -170,7 +170,7 @@ public class StatefulRetryOperationsInterceptor implements MethodInterceptor { } public Object doWithRetry(RetryContext context) throws Exception { - context.setAttribute(RetryContext.STATS_NAME, label); + context.setAttribute(RetryContext.NAME, label); try { return invocation.proceed(); } diff --git a/src/main/java/org/springframework/retry/stats/DefaultRetryStatistics.java b/src/main/java/org/springframework/retry/stats/DefaultRetryStatistics.java new file mode 100644 index 0000000..4e7240f --- /dev/null +++ b/src/main/java/org/springframework/retry/stats/DefaultRetryStatistics.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * 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 org.springframework.retry.stats; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.retry.RetryStatistics; + +/** + * @author Dave Syer + * + */ +public class DefaultRetryStatistics implements RetryStatistics { + + private String name; + private AtomicInteger startedCount = new AtomicInteger(); + private AtomicInteger completeCount = new AtomicInteger(); + private AtomicInteger recoveryCount = new AtomicInteger(); + private AtomicInteger errorCount = new AtomicInteger(); + private AtomicInteger abortCount = new AtomicInteger(); + + DefaultRetryStatistics() { + } + + public DefaultRetryStatistics(String name) { + this.name = name; + } + + public DefaultRetryStatistics(String name, int startedCount, int completeCount, + int recoveryCount, int errorCount, int abortCount) { + this.name = name; + this.startedCount = new AtomicInteger(startedCount); + this.completeCount = new AtomicInteger(completeCount); + this.recoveryCount = new AtomicInteger(recoveryCount); + this.errorCount = new AtomicInteger(errorCount); + this.abortCount = new AtomicInteger(abortCount); + } + + @Override + public int getCompleteCount() { + return completeCount.get(); + } + + @Override + public int getStartedCount() { + return startedCount.get(); + } + + @Override + public int getErrorCount() { + return errorCount.get(); + } + + @Override + public int getAbortCount() { + return abortCount.get(); + } + + @Override + public String getName() { + return name; + } + + @Override + public int getRecoveryCount() { + return recoveryCount.get(); + } + + public void setName(String name) { + this.name = name; + } + + public void setStartedCount(int startedCount) { + this.startedCount.set(startedCount); + } + + public void setCompleteCount(int completeCount) { + this.completeCount.set(completeCount); + } + + public void setRecoveryCount(int recoveryCount) { + this.recoveryCount.set(recoveryCount); + } + + public void setErrorCount(int errorCount) { + this.errorCount.set(errorCount); + } + + public void setAbortCount(int abortCount) { + this.abortCount.set(abortCount); + } + + public void incrementStartedCount() { + this.startedCount.incrementAndGet(); + } + + public void incrementCompleteCount() { + this.completeCount.incrementAndGet(); + } + + public void incrementRecoveryCount() { + this.recoveryCount.incrementAndGet(); + } + + public void incrementErrorCount() { + this.errorCount.incrementAndGet(); + } + + public void incrementAbortCount() { + this.abortCount.incrementAndGet(); + } + + @Override + public String toString() { + return "DefaultRetryStatistics [name=" + name + ", startedCount=" + startedCount + + ", completeCount=" + completeCount + ", recoveryCount=" + recoveryCount + + ", errorCount=" + errorCount + ", abortCount=" + abortCount + "]"; + } + +} diff --git a/src/main/java/org/springframework/retry/stats/DefaultStatisticsRepository.java b/src/main/java/org/springframework/retry/stats/DefaultStatisticsRepository.java new file mode 100644 index 0000000..33d3c20 --- /dev/null +++ b/src/main/java/org/springframework/retry/stats/DefaultStatisticsRepository.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * 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 org.springframework.retry.stats; + +import java.util.ArrayList; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.retry.RetryStatistics; + +/** + * @author Dave Syer + * + */ +public class DefaultStatisticsRepository implements StatisticsRepository { + + private ConcurrentMap map = new ConcurrentHashMap(); + + @Override + public RetryStatistics findOne(String name) { + return map.get(name); + } + + @Override + public Iterable findAll() { + return new ArrayList(map.values()); + } + + @Override + public void addStarted(String name) { + getStatistics(name).incrementStartedCount(); + } + + @Override + public void addError(String name) { + getStatistics(name).incrementErrorCount(); + } + + @Override + public void addRecovery(String name) { + getStatistics(name).incrementRecoveryCount(); + } + + @Override + public void addComplete(String name) { + getStatistics(name).incrementCompleteCount(); + } + + @Override + public void addAbort(String name) { + getStatistics(name).incrementAbortCount(); + } + + private DefaultRetryStatistics getStatistics(String name) { + DefaultRetryStatistics stats; + if (!map.containsKey(name)) { + map.putIfAbsent(name, new DefaultRetryStatistics(name)); + } + stats = map.get(name); + return stats; + } + +} diff --git a/src/main/java/org/springframework/retry/stats/StatisticsListener.java b/src/main/java/org/springframework/retry/stats/StatisticsListener.java new file mode 100644 index 0000000..dfea859 --- /dev/null +++ b/src/main/java/org/springframework/retry/stats/StatisticsListener.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * 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 org.springframework.retry.stats; + +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.listener.RetryListenerSupport; + +/** + * @author Dave Syer + * + */ +public class StatisticsListener extends RetryListenerSupport { + + private final StatisticsRepository repository; + + public StatisticsListener(StatisticsRepository repository) { + this.repository = repository; + } + + @Override + public void close(RetryContext context, + RetryCallback callback, Throwable throwable) { + String name = getName(context); + if (name != null) { + if (!isExhausted(context)) { + // If exhausted and stateful then the retry callback was not called. If + // exhausted and stateless it was called, but the started counter was + // already incremented. + repository.addStarted(name); + } + if (isRecovered(context)) { + repository.addRecovery(name); + } + else if (isExhausted(context)) { + repository.addAbort(name); + } + else if (isClosed(context)) { + repository.addComplete(name); + } + } + } + + @Override + public void onError(RetryContext context, + RetryCallback callback, Throwable throwable) { + String name = getName(context); + if (name != null) { + if (!hasState(context)) { + // Stateless retry involves starting the retry callback once per error + // without closing the context, so we need to increment the started count + repository.addStarted(name); + } + repository.addError(name); + } + } + + private boolean isExhausted(RetryContext context) { + return context.hasAttribute(RetryContext.EXHAUSTED); + } + + private boolean isClosed(RetryContext context) { + return context.hasAttribute(RetryContext.CLOSED); + } + + private boolean isRecovered(RetryContext context) { + return context.hasAttribute(RetryContext.RECOVERED); + } + + private boolean hasState(RetryContext context) { + return context.hasAttribute(RetryContext.STATE_KEY); + } + + private String getName(RetryContext context) { + return (String) context.getAttribute(RetryContext.NAME); + } +} diff --git a/src/main/java/org/springframework/retry/stats/StatisticsRepository.java b/src/main/java/org/springframework/retry/stats/StatisticsRepository.java new file mode 100644 index 0000000..d5037ef --- /dev/null +++ b/src/main/java/org/springframework/retry/stats/StatisticsRepository.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * 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 org.springframework.retry.stats; + +import org.springframework.retry.RetryStatistics; + +/** + * @author Dave Syer + * + */ +public interface StatisticsRepository { + + RetryStatistics findOne(String name); + + Iterable findAll(); + + void addStarted(String name); + + void addError(String name); + + void addRecovery(String name); + + void addComplete(String name); + + void addAbort(String name); + +} diff --git a/src/main/java/org/springframework/retry/support/RetryTemplate.java b/src/main/java/org/springframework/retry/support/RetryTemplate.java index a57853c..fce5485 100644 --- a/src/main/java/org/springframework/retry/support/RetryTemplate.java +++ b/src/main/java/org/springframework/retry/support/RetryTemplate.java @@ -371,10 +371,12 @@ public class RetryTemplate implements RetryOperations { if (succeeded) { this.retryContextCache.remove(state.getKey()); retryPolicy.close(context); + context.setAttribute(RetryContext.CLOSED, true); } } else { retryPolicy.close(context); + context.setAttribute(RetryContext.CLOSED, true); } } @@ -417,14 +419,14 @@ public class RetryTemplate implements RetryOperations { Object key = state.getKey(); if (state.isForceRefresh()) { - return doOpenInternal(retryPolicy); + return doOpenInternal(retryPolicy, state); } // If there is no cache hit we can avoid the possible expense of the // cache re-hydration. if (!this.retryContextCache.containsKey(key)) { // The cache is only used if there is a failure. - return doOpenInternal(retryPolicy); + return doOpenInternal(retryPolicy, state); } RetryContext context = this.retryContextCache.get(key); @@ -437,15 +439,23 @@ public class RetryTemplate implements RetryOperations { } // The cache could have been expired in between calls to // containsKey(), so we have to live with this: - return doOpenInternal(retryPolicy); + return doOpenInternal(retryPolicy, state); } return context; } + private RetryContext doOpenInternal(RetryPolicy retryPolicy, RetryState state) { + RetryContext context = retryPolicy.open(RetrySynchronizationManager.getContext()); + if (state!=null) { + context.setAttribute(RetryContext.STATE_KEY, state.getKey()); + } + return context; + } + private RetryContext doOpenInternal(RetryPolicy retryPolicy) { - return retryPolicy.open(RetrySynchronizationManager.getContext()); + return doOpenInternal(retryPolicy, null); } /** @@ -465,10 +475,12 @@ public class RetryTemplate implements RetryOperations { */ protected T handleRetryExhausted(RecoveryCallback recoveryCallback, RetryContext context, RetryState state) throws Throwable { + context.setAttribute(RetryContext.EXHAUSTED, true); if (state != null) { this.retryContextCache.remove(state.getKey()); } if (recoveryCallback != null) { + context.setAttribute(RetryContext.RECOVERED, true); return recoveryCallback.recover(context); } if (state != null) { diff --git a/src/test/java/org/springframework/retry/stats/StatisticsListenerTests.java b/src/test/java/org/springframework/retry/stats/StatisticsListenerTests.java new file mode 100644 index 0000000..a080ace --- /dev/null +++ b/src/test/java/org/springframework/retry/stats/StatisticsListenerTests.java @@ -0,0 +1,232 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * 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 org.springframework.retry.stats; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.util.Collections; + +import org.junit.Test; +import org.springframework.retry.RecoveryCallback; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryState; +import org.springframework.retry.RetryStatistics; +import org.springframework.retry.listener.RetryListenerSupport; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.DefaultRetryState; +import org.springframework.retry.support.RetryTemplate; + +/** + * @author Dave Syer + * + */ +public class StatisticsListenerTests { + + private StatisticsRepository repository = new DefaultStatisticsRepository(); + private StatisticsListener listener = new StatisticsListener(repository); + + @Test + public void testStatelessSuccessful() throws Throwable { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setListeners(new RetryListenerSupport[] { listener }); + for (int x = 1; x <= 10; x++) { + MockRetryCallback callback = new MockRetryCallback(); + callback.setAttemptsBeforeSuccess(x); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(x, + Collections., Boolean>singletonMap( + Exception.class, true))); + retryTemplate.execute(callback); + assertEquals(x, callback.attempts); + RetryStatistics stats = repository.findOne("test"); + // System.err.println(stats); + assertNotNull(stats); + assertEquals(x, stats.getCompleteCount()); + assertEquals((x + 1) * x / 2, stats.getStartedCount()); + assertEquals(stats.getStartedCount(), stats.getErrorCount() + x); + } + } + + @Test + public void testStatefulSuccessful() throws Throwable { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setListeners(new RetryListenerSupport[] { listener }); + RetryState state = new DefaultRetryState("foo"); + for (int x = 1; x <= 10; x++) { + MockRetryCallback callback = new MockRetryCallback(); + callback.setAttemptsBeforeSuccess(x); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(x, + Collections., Boolean>singletonMap( + Exception.class, true))); + for (int i = 0; i < x; i++) { + try { + retryTemplate.execute(callback, state); + } + catch (Exception e) { + // don't care + } + } + assertEquals(x, callback.attempts); + RetryStatistics stats = repository.findOne("test"); + // System.err.println(stats); + assertNotNull(stats); + assertEquals(x, stats.getCompleteCount()); + assertEquals((x + 1) * x / 2, stats.getStartedCount()); + assertEquals(stats.getStartedCount(), stats.getErrorCount() + x); + } + } + + @Test + public void testStatelessUnsuccessful() throws Throwable { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setListeners(new RetryListenerSupport[] { listener }); + for (int x = 1; x <= 10; x++) { + MockRetryCallback callback = new MockRetryCallback(); + callback.setAttemptsBeforeSuccess(x + 1); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(x, + Collections., Boolean>singletonMap( + Exception.class, true))); + try { + retryTemplate.execute(callback); + } + catch (Exception e) { + // not interested + } + assertEquals(x, callback.attempts); + RetryStatistics stats = repository.findOne("test"); + assertNotNull(stats); + assertEquals(x, stats.getAbortCount()); + assertEquals((x + 1) * x / 2, stats.getStartedCount()); + assertEquals(stats.getStartedCount(), stats.getErrorCount()); + } + } + + @Test + public void testStatefulUnsuccessful() throws Throwable { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setListeners(new RetryListenerSupport[] { listener }); + RetryState state = new DefaultRetryState("foo"); + for (int x = 1; x <= 10; x++) { + MockRetryCallback callback = new MockRetryCallback(); + callback.setAttemptsBeforeSuccess(x + 1); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(x, + Collections., Boolean>singletonMap( + Exception.class, true))); + for (int i = 0; i < x+1; i++) { + try { + retryTemplate.execute(callback, state); + } + catch (Exception e) { + // don't care + } + } + assertEquals(x, callback.attempts); + RetryStatistics stats = repository.findOne("test"); + // System.err.println(stats); + assertNotNull(stats); + assertEquals(x, stats.getAbortCount()); + assertEquals((x + 1) * x / 2, stats.getStartedCount()); + assertEquals(stats.getStartedCount(), stats.getErrorCount()); + } + } + + @Test + public void testStatelessRecovery() throws Throwable { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setListeners(new RetryListenerSupport[] { listener }); + for (int x = 1; x <= 10; x++) { + MockRetryCallback callback = new MockRetryCallback(); + callback.setAttemptsBeforeSuccess(x + 1); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(x, + Collections., Boolean>singletonMap( + Exception.class, true))); + retryTemplate.execute(callback, new RecoveryCallback() { + @Override + public Object recover(RetryContext context) throws Exception { + return null; + } + }); + assertEquals(x, callback.attempts); + RetryStatistics stats = repository.findOne("test"); + // System.err.println(stats); + assertNotNull(stats); + assertEquals(x, stats.getRecoveryCount()); + assertEquals((x + 1) * x / 2, stats.getStartedCount()); + assertEquals(stats.getStartedCount(), stats.getErrorCount()); + } + } + + @Test + public void testStatefulRecovery() throws Throwable { + RetryTemplate retryTemplate = new RetryTemplate(); + retryTemplate.setListeners(new RetryListenerSupport[] { listener }); + RetryState state = new DefaultRetryState("foo"); + for (int x = 1; x <= 10; x++) { + MockRetryCallback callback = new MockRetryCallback(); + callback.setAttemptsBeforeSuccess(x + 1); + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(x, + Collections., Boolean>singletonMap( + Exception.class, true))); + for (int i = 0; i < x+1; i++) { + try { + retryTemplate.execute(callback, new RecoveryCallback() { + @Override + public Object recover(RetryContext context) throws Exception { + return null; + } + }, state); + } + catch (Exception e) { + // don't care + } + } + assertEquals(x, callback.attempts); + RetryStatistics stats = repository.findOne("test"); + // System.err.println(stats); + assertNotNull(stats); + assertEquals(x, stats.getRecoveryCount()); + assertEquals((x + 1) * x / 2, stats.getStartedCount()); + assertEquals(stats.getStartedCount(), stats.getErrorCount()); + } + } + + private static class MockRetryCallback implements RetryCallback { + + private int attempts; + + private int attemptsBeforeSuccess; + + private Exception exceptionToThrow = new Exception(); + + @Override + public Object doWithRetry(RetryContext status) throws Exception { + status.setAttribute(RetryContext.NAME, "test"); + this.attempts++; + if (this.attempts < this.attemptsBeforeSuccess) { + throw this.exceptionToThrow; + } + return null; + } + + public void setAttemptsBeforeSuccess(int attemptsBeforeSuccess) { + this.attemptsBeforeSuccess = attemptsBeforeSuccess; + } + + } + +}