mirror of https://github.com/hyperledger/besu
Separate download state tracking from WorldStateDownloader (#967)
Signed-off-by: Adrian Sutton <adrian.sutton@consensys.net>pull/2/head
parent
b81ad01b0f
commit
9f8d14522e
@ -0,0 +1,212 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2019 ConsenSys AG. |
||||||
|
* |
||||||
|
* 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 tech.pegasys.pantheon.ethereum.eth.sync.worldstate; |
||||||
|
|
||||||
|
import tech.pegasys.pantheon.ethereum.core.BlockHeader; |
||||||
|
import tech.pegasys.pantheon.ethereum.eth.manager.task.EthTask; |
||||||
|
import tech.pegasys.pantheon.ethereum.worldstate.WorldStateStorage; |
||||||
|
import tech.pegasys.pantheon.ethereum.worldstate.WorldStateStorage.Updater; |
||||||
|
import tech.pegasys.pantheon.services.queue.TaskQueue; |
||||||
|
import tech.pegasys.pantheon.services.queue.TaskQueue.Task; |
||||||
|
import tech.pegasys.pantheon.util.ExceptionUtils; |
||||||
|
import tech.pegasys.pantheon.util.bytes.BytesValue; |
||||||
|
|
||||||
|
import java.util.Collections; |
||||||
|
import java.util.Set; |
||||||
|
import java.util.concurrent.ArrayBlockingQueue; |
||||||
|
import java.util.concurrent.CancellationException; |
||||||
|
import java.util.concurrent.CompletableFuture; |
||||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||||
|
import java.util.concurrent.TimeUnit; |
||||||
|
import java.util.concurrent.atomic.AtomicBoolean; |
||||||
|
import java.util.stream.Stream; |
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager; |
||||||
|
import org.apache.logging.log4j.Logger; |
||||||
|
|
||||||
|
class WorldDownloadState { |
||||||
|
private static final Logger LOG = LogManager.getLogger(); |
||||||
|
|
||||||
|
private final TaskQueue<NodeDataRequest> pendingRequests; |
||||||
|
private final ArrayBlockingQueue<Task<NodeDataRequest>> requestsToPersist; |
||||||
|
private final int maxOutstandingRequests; |
||||||
|
private final Set<EthTask<?>> outstandingRequests = |
||||||
|
Collections.newSetFromMap(new ConcurrentHashMap<>()); |
||||||
|
private final AtomicBoolean sendingRequests = new AtomicBoolean(false); |
||||||
|
private final CompletableFuture<Void> internalFuture; |
||||||
|
private final CompletableFuture<Void> downloadFuture; |
||||||
|
private boolean waitingForNewPeer = false; |
||||||
|
private BytesValue rootNodeData; |
||||||
|
private EthTask<?> persistenceTask; |
||||||
|
|
||||||
|
public WorldDownloadState( |
||||||
|
final TaskQueue<NodeDataRequest> pendingRequests, |
||||||
|
final ArrayBlockingQueue<Task<NodeDataRequest>> requestsToPersist, |
||||||
|
final int maxOutstandingRequests) { |
||||||
|
this.pendingRequests = pendingRequests; |
||||||
|
this.requestsToPersist = requestsToPersist; |
||||||
|
this.maxOutstandingRequests = maxOutstandingRequests; |
||||||
|
this.internalFuture = new CompletableFuture<>(); |
||||||
|
this.downloadFuture = new CompletableFuture<>(); |
||||||
|
this.internalFuture.whenComplete(this::cleanup); |
||||||
|
this.downloadFuture.exceptionally( |
||||||
|
error -> { |
||||||
|
// Propagate cancellation back to our internal future.
|
||||||
|
if (error instanceof CancellationException) { |
||||||
|
this.internalFuture.cancel(true); |
||||||
|
} |
||||||
|
return null; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private synchronized void cleanup(final Void result, final Throwable error) { |
||||||
|
// Handle cancellations
|
||||||
|
if (internalFuture.isCancelled()) { |
||||||
|
LOG.info("World state download cancelled"); |
||||||
|
} else if (error != null) { |
||||||
|
if (!(ExceptionUtils.rootCause(error) instanceof StalledDownloadException)) { |
||||||
|
LOG.info("World state download failed. ", error); |
||||||
|
} |
||||||
|
} |
||||||
|
if (persistenceTask != null) { |
||||||
|
persistenceTask.cancel(); |
||||||
|
} |
||||||
|
for (final EthTask<?> outstandingRequest : outstandingRequests) { |
||||||
|
outstandingRequest.cancel(); |
||||||
|
} |
||||||
|
pendingRequests.clear(); |
||||||
|
requestsToPersist.clear(); |
||||||
|
if (error != null) { |
||||||
|
downloadFuture.completeExceptionally(error); |
||||||
|
} else { |
||||||
|
downloadFuture.complete(result); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void whileAdditionalRequestsCanBeSent(final Runnable action) { |
||||||
|
while (shouldRequestNodeData()) { |
||||||
|
if (sendingRequests.compareAndSet(false, true)) { |
||||||
|
try { |
||||||
|
action.run(); |
||||||
|
} finally { |
||||||
|
sendingRequests.set(false); |
||||||
|
} |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized void setWaitingForNewPeer(final boolean waitingForNewPeer) { |
||||||
|
this.waitingForNewPeer = waitingForNewPeer; |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized void addOutstandingTask(final EthTask<?> task) { |
||||||
|
outstandingRequests.add(task); |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized void removeOutstandingTask(final EthTask<?> task) { |
||||||
|
outstandingRequests.remove(task); |
||||||
|
} |
||||||
|
|
||||||
|
public int getOutstandingRequestCount() { |
||||||
|
return outstandingRequests.size(); |
||||||
|
} |
||||||
|
|
||||||
|
private synchronized boolean shouldRequestNodeData() { |
||||||
|
return !internalFuture.isDone() |
||||||
|
&& outstandingRequests.size() < maxOutstandingRequests |
||||||
|
&& !pendingRequests.isEmpty() |
||||||
|
&& !waitingForNewPeer; |
||||||
|
} |
||||||
|
|
||||||
|
public CompletableFuture<Void> getDownloadFuture() { |
||||||
|
return downloadFuture; |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized void setPersistenceTask(final EthTask<?> persistenceTask) { |
||||||
|
this.persistenceTask = persistenceTask; |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized void enqueueRequest(final NodeDataRequest request) { |
||||||
|
if (!internalFuture.isDone()) { |
||||||
|
pendingRequests.enqueue(request); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized void enqueueRequests(final Stream<NodeDataRequest> requests) { |
||||||
|
if (!internalFuture.isDone()) { |
||||||
|
requests.forEach(pendingRequests::enqueue); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized Task<NodeDataRequest> dequeueRequest() { |
||||||
|
if (internalFuture.isDone()) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
return pendingRequests.dequeue(); |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized void setRootNodeData(final BytesValue rootNodeData) { |
||||||
|
this.rootNodeData = rootNodeData; |
||||||
|
} |
||||||
|
|
||||||
|
public ArrayBlockingQueue<Task<NodeDataRequest>> getRequestsToPersist() { |
||||||
|
return requestsToPersist; |
||||||
|
} |
||||||
|
|
||||||
|
public void addToPersistenceQueue(final Task<NodeDataRequest> task) { |
||||||
|
while (!internalFuture.isDone()) { |
||||||
|
try { |
||||||
|
if (requestsToPersist.offer(task, 1, TimeUnit.SECONDS)) { |
||||||
|
break; |
||||||
|
} |
||||||
|
} catch (final InterruptedException e) { |
||||||
|
task.markFailed(); |
||||||
|
Thread.currentThread().interrupt(); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public int getPersistenceQueueSize() { |
||||||
|
return requestsToPersist.size(); |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized void markAsStalled(final int maxNodeRequestRetries) { |
||||||
|
final String message = |
||||||
|
"Download stalled due to too many failures to retrieve node data (>" |
||||||
|
+ maxNodeRequestRetries |
||||||
|
+ " failures)"; |
||||||
|
final WorldStateDownloaderException e = new StalledDownloadException(message); |
||||||
|
internalFuture.completeExceptionally(e); |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized boolean checkCompletion( |
||||||
|
final WorldStateStorage worldStateStorage, final BlockHeader header) { |
||||||
|
if (!internalFuture.isDone() && pendingRequests.allTasksCompleted()) { |
||||||
|
final Updater updater = worldStateStorage.updater(); |
||||||
|
updater.putAccountStateTrieNode(header.getStateRoot(), rootNodeData); |
||||||
|
updater.commit(); |
||||||
|
internalFuture.complete(null); |
||||||
|
LOG.info("Finished downloading world state from peers"); |
||||||
|
return true; |
||||||
|
} else { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public synchronized boolean isDownloading() { |
||||||
|
return !internalFuture.isDone(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,237 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2019 ConsenSys AG. |
||||||
|
* |
||||||
|
* 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 tech.pegasys.pantheon.ethereum.eth.sync.worldstate; |
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||||
|
import static org.assertj.core.api.Assertions.fail; |
||||||
|
import static org.mockito.Mockito.doAnswer; |
||||||
|
import static org.mockito.Mockito.mock; |
||||||
|
import static org.mockito.Mockito.times; |
||||||
|
import static org.mockito.Mockito.verify; |
||||||
|
import static tech.pegasys.pantheon.ethereum.eth.sync.worldstate.NodeDataRequest.createAccountDataRequest; |
||||||
|
|
||||||
|
import tech.pegasys.pantheon.ethereum.core.BlockHeader; |
||||||
|
import tech.pegasys.pantheon.ethereum.core.BlockHeaderTestFixture; |
||||||
|
import tech.pegasys.pantheon.ethereum.core.Hash; |
||||||
|
import tech.pegasys.pantheon.ethereum.eth.manager.task.EthTask; |
||||||
|
import tech.pegasys.pantheon.ethereum.storage.keyvalue.KeyValueStorageWorldStateStorage; |
||||||
|
import tech.pegasys.pantheon.ethereum.worldstate.WorldStateStorage; |
||||||
|
import tech.pegasys.pantheon.services.kvstore.InMemoryKeyValueStorage; |
||||||
|
import tech.pegasys.pantheon.services.queue.InMemoryTaskQueue; |
||||||
|
import tech.pegasys.pantheon.services.queue.TaskQueue.Task; |
||||||
|
import tech.pegasys.pantheon.util.bytes.BytesValue; |
||||||
|
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue; |
||||||
|
import java.util.concurrent.CompletableFuture; |
||||||
|
import java.util.stream.Stream; |
||||||
|
|
||||||
|
import org.junit.Before; |
||||||
|
import org.junit.Test; |
||||||
|
|
||||||
|
public class WorldDownloadStateTest { |
||||||
|
|
||||||
|
private static final BytesValue ROOT_NODE_DATA = BytesValue.of(1, 2, 3, 4); |
||||||
|
private static final Hash ROOT_NODE_HASH = Hash.hash(ROOT_NODE_DATA); |
||||||
|
private static final int MAX_OUTSTANDING_REQUESTS = 3; |
||||||
|
|
||||||
|
private final WorldStateStorage worldStateStorage = |
||||||
|
new KeyValueStorageWorldStateStorage(new InMemoryKeyValueStorage()); |
||||||
|
|
||||||
|
private final BlockHeader header = |
||||||
|
new BlockHeaderTestFixture().stateRoot(ROOT_NODE_HASH).buildHeader(); |
||||||
|
private final InMemoryTaskQueue<NodeDataRequest> pendingRequests = new InMemoryTaskQueue<>(); |
||||||
|
private final ArrayBlockingQueue<Task<NodeDataRequest>> requestsToPersist = |
||||||
|
new ArrayBlockingQueue<>(100); |
||||||
|
|
||||||
|
private final WorldDownloadState downloadState = |
||||||
|
new WorldDownloadState(pendingRequests, requestsToPersist, MAX_OUTSTANDING_REQUESTS); |
||||||
|
|
||||||
|
private final CompletableFuture<Void> future = downloadState.getDownloadFuture(); |
||||||
|
|
||||||
|
@Before |
||||||
|
public void setUp() { |
||||||
|
downloadState.setRootNodeData(ROOT_NODE_DATA); |
||||||
|
assertThat(downloadState.isDownloading()).isTrue(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldCompleteReturnedFutureWhenNoPendingTasksRemain() { |
||||||
|
downloadState.checkCompletion(worldStateStorage, header); |
||||||
|
|
||||||
|
assertThat(future).isCompleted(); |
||||||
|
assertThat(downloadState.isDownloading()).isFalse(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldStoreRootNodeBeforeReturnedFutureCompletes() { |
||||||
|
final CompletableFuture<Void> postFutureChecks = |
||||||
|
future.thenAccept( |
||||||
|
result -> |
||||||
|
assertThat(worldStateStorage.getAccountStateTrieNode(ROOT_NODE_HASH)) |
||||||
|
.contains(ROOT_NODE_DATA)); |
||||||
|
|
||||||
|
downloadState.checkCompletion(worldStateStorage, header); |
||||||
|
|
||||||
|
assertThat(future).isCompleted(); |
||||||
|
assertThat(postFutureChecks).isCompleted(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldNotCompleteWhenThereArePendingTasks() { |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
|
||||||
|
downloadState.checkCompletion(worldStateStorage, header); |
||||||
|
|
||||||
|
assertThat(future).isNotDone(); |
||||||
|
assertThat(worldStateStorage.getAccountStateTrieNode(ROOT_NODE_HASH)).isEmpty(); |
||||||
|
assertThat(downloadState.isDownloading()).isTrue(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
@SuppressWarnings("unchecked") |
||||||
|
public void shouldCancelOutstandingTasksWhenFutureIsCancelled() { |
||||||
|
final EthTask<?> persistenceTask = mock(EthTask.class); |
||||||
|
final EthTask<?> outstandingTask1 = mock(EthTask.class); |
||||||
|
final EthTask<?> outstandingTask2 = mock(EthTask.class); |
||||||
|
final Task<NodeDataRequest> toPersist1 = mock(Task.class); |
||||||
|
final Task<NodeDataRequest> toPersist2 = mock(Task.class); |
||||||
|
downloadState.setPersistenceTask(persistenceTask); |
||||||
|
downloadState.addOutstandingTask(outstandingTask1); |
||||||
|
downloadState.addOutstandingTask(outstandingTask2); |
||||||
|
|
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY)); |
||||||
|
requestsToPersist.add(toPersist1); |
||||||
|
requestsToPersist.add(toPersist2); |
||||||
|
|
||||||
|
future.cancel(true); |
||||||
|
|
||||||
|
verify(persistenceTask).cancel(); |
||||||
|
verify(outstandingTask1).cancel(); |
||||||
|
verify(outstandingTask2).cancel(); |
||||||
|
|
||||||
|
assertThat(pendingRequests.isEmpty()).isTrue(); |
||||||
|
assertThat(requestsToPersist).isEmpty(); |
||||||
|
assertThat(downloadState.isDownloading()).isFalse(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldNotSendAdditionalRequestsWhenWaitingForANewPeer() { |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
|
||||||
|
downloadState.setWaitingForNewPeer(true); |
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(mustNotBeCalled()); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldResumeSendingAdditionalRequestsWhenNoLongerWaitingForPeer() { |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
final Runnable sendRequest = |
||||||
|
mockWithAction(() -> downloadState.addOutstandingTask(mock(EthTask.class))); |
||||||
|
|
||||||
|
downloadState.setWaitingForNewPeer(true); |
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(mustNotBeCalled()); |
||||||
|
|
||||||
|
downloadState.setWaitingForNewPeer(false); |
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(sendRequest); |
||||||
|
verify(sendRequest, times(MAX_OUTSTANDING_REQUESTS)).run(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldStopSendingAdditionalRequestsWhenPendingRequestsIsEmpty() { |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
|
||||||
|
final Runnable sendRequest = mockWithAction(pendingRequests::dequeue); |
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(sendRequest); |
||||||
|
|
||||||
|
verify(sendRequest, times(2)).run(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldStopSendingAdditionalRequestsWhenMaximumOutstandingRequestCountReached() { |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
final Runnable sendRequest = |
||||||
|
mockWithAction(() -> downloadState.addOutstandingTask(mock(EthTask.class))); |
||||||
|
|
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(sendRequest); |
||||||
|
verify(sendRequest, times(MAX_OUTSTANDING_REQUESTS)).run(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldStopSendingAdditionalRequestsWhenFutureIsCancelled() { |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
final Runnable sendRequest = mockWithAction(() -> future.cancel(true)); |
||||||
|
|
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(sendRequest); |
||||||
|
verify(sendRequest, times(1)).run(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldStopSendingAdditionalRequestsWhenDownloadIsMarkedAsStalled() { |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
final Runnable sendRequest = mockWithAction(() -> downloadState.markAsStalled(1)); |
||||||
|
|
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(sendRequest); |
||||||
|
verify(sendRequest, times(1)).run(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldNotAllowMultipleCallsToSendAdditionalRequestsAtOnce() { |
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
final Runnable sendRequest = |
||||||
|
mockWithAction( |
||||||
|
() -> { |
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(mustNotBeCalled()); |
||||||
|
downloadState.addOutstandingTask(mock(EthTask.class)); |
||||||
|
}); |
||||||
|
|
||||||
|
downloadState.whileAdditionalRequestsCanBeSent(sendRequest); |
||||||
|
verify(sendRequest, times(MAX_OUTSTANDING_REQUESTS)).run(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldNotEnqueueRequestsAfterDownloadIsStalled() { |
||||||
|
downloadState.checkCompletion(worldStateStorage, header); |
||||||
|
|
||||||
|
downloadState.enqueueRequests(Stream.of(createAccountDataRequest(Hash.EMPTY_TRIE_HASH))); |
||||||
|
downloadState.enqueueRequest(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
|
||||||
|
assertThat(pendingRequests.isEmpty()).isTrue(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test // Sanity check for the test structure
|
||||||
|
public void shouldFailWhenMustNotBeCalledIsCalled() { |
||||||
|
|
||||||
|
pendingRequests.enqueue(createAccountDataRequest(Hash.EMPTY_TRIE_HASH)); |
||||||
|
assertThatThrownBy(() -> downloadState.whileAdditionalRequestsCanBeSent(mustNotBeCalled())) |
||||||
|
.hasMessage("Unexpected invocation"); |
||||||
|
} |
||||||
|
|
||||||
|
private Runnable mustNotBeCalled() { |
||||||
|
return () -> fail("Unexpected invocation"); |
||||||
|
} |
||||||
|
|
||||||
|
private Runnable mockWithAction(final Runnable action) { |
||||||
|
final Runnable runnable = mock(Runnable.class); |
||||||
|
doAnswer( |
||||||
|
invocation -> { |
||||||
|
action.run(); |
||||||
|
return null; |
||||||
|
}) |
||||||
|
.when(runnable) |
||||||
|
.run(); |
||||||
|
return runnable; |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue