Compare commits

...

9 Commits

Author SHA1 Message Date
catbref
5eafdf3c80 Bump version to 1.0.4 2020-03-19 14:30:02 +00:00
catbref
d7c26c27e1 Add debugging message to Peer regarding lost PING replies 2020-03-19 14:29:47 +00:00
catbref
2d18dd62eb Translation / API response improvements 2020-03-19 14:21:48 +00:00
catbref
51fd177d79 Add access to network engine stats
Added API call GET /peers/enginestats to allow external monitoring.

Extract all engine stats in one synchronized block, instead of
separate calls, for better consistency.
2020-03-19 11:19:49 +00:00
catbref
c4643538f1 Improve Synchronizer to reduce network load
Synchronizer now bails out early when trying to find common block with
a peer. There's no need to keep searching if common block is too far
behind that a TOO_DIVERGENT result would be returned.

fetchSummariesFromCommonBlock() reworked to return a useful
SynchronizationResult directly instead of caller trying to infer
what happened based on null/empty returned list!
2020-03-19 11:11:35 +00:00
catbref
0edadaf901 Remove warning 2020-03-19 11:11:05 +00:00
catbref
c05533fb71 Fix comment 2020-03-19 11:10:40 +00:00
catbref
db270f559f Improve minting/syncing status reporting
Added API call GET /admin/status which reports whether minting
is possible (have minting keys & up-to-date) and whether node is
currently attempting to sync.

Corresponding change to system tray mouseover text.

Corresponding text added to SysTray transaction resources.
2020-03-19 11:07:56 +00:00
catbref
79f7f68b0c Change when BlockMinter decides it's ok to mint a block
Previously BlockMinter would attempt to mint if there were at least
'minBlockchainPeers' connected peers and none of them had an
up-to-date block and we did. This was maybe useful for minting block 2
but possibly causes minting chain islands where a badly connected
node mints by itself, even though connected to not up-to-date peers.

Now BlockMinter requires 'minBlockchainPeers' up-to-date peers, not
simply just connected. This should let synchronization bring the
node up-to-date but does require the node to have better peers.

Currently, the default for minBlockchainPeers is 10. So a node
requires 10 up-to-date peers before it will consider minting. It
might be possible to reduce this in the future to lessen network load.
2020-03-19 10:58:53 +00:00
18 changed files with 181 additions and 81 deletions

View File

@@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>org.qortal</groupId>
<artifactId>qortal</artifactId>
<version>1.0</version>
<version>1.0.4</version>
<packaging>jar</packaging>
<properties>
<bitcoin.version>0.15.4</bitcoin.version>

View File

@@ -0,0 +1,15 @@
package org.qortal.api.model;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
@XmlAccessorType(XmlAccessType.FIELD)
public class NodeStatus {
public boolean isMintingPossible;
public boolean isSynchronizing;
public NodeStatus() {
}
}

View File

@@ -45,6 +45,7 @@ import org.qortal.api.ApiExceptionFactory;
import org.qortal.api.Security;
import org.qortal.api.model.ActivitySummary;
import org.qortal.api.model.NodeInfo;
import org.qortal.api.model.NodeStatus;
import org.qortal.block.BlockChain;
import org.qortal.controller.Controller;
import org.qortal.controller.Synchronizer.SynchronizationResult;
@@ -120,6 +121,27 @@ public class AdminResource {
return nodeInfo;
}
@GET
@Path("/status")
@Operation(
summary = "Fetch node status",
responses = {
@ApiResponse(
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = NodeStatus.class))
)
}
)
public NodeStatus status() {
Security.checkApiCallAllowed(request);
NodeStatus nodeStatus = new NodeStatus();
nodeStatus.isMintingPossible = Controller.getInstance().isMintingPossible();
nodeStatus.isSynchronizing = Controller.getInstance().isSynchronizing();
return nodeStatus;
}
@GET
@Path("/stop")
@Operation(

View File

@@ -31,6 +31,7 @@ import org.qortal.network.PeerAddress;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.utils.ExecuteProduceConsume;
@Path("/peers")
@Tag(name = "Peers")
@@ -108,6 +109,29 @@ public class PeersResource {
return Network.getInstance().getSelfPeers();
}
@GET
@Path("/enginestats")
@Operation(
summary = "Fetch statistics snapshot for networking engine",
responses = {
@ApiResponse(
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
array = @ArraySchema(
schema = @Schema(
implementation = ExecuteProduceConsume.StatsSnapshot.class
)
)
)
)
}
)
public ExecuteProduceConsume.StatsSnapshot getEngineStats() {
Security.checkApiCallAllowed(request);
return Network.getInstance().getStatsSnapshot();
}
@POST
@Operation(
summary = "Add new peer address",

View File

@@ -573,7 +573,7 @@ public class TransactionsResource {
public static ApiException createTransactionInvalidException(HttpServletRequest request, ValidationResult result) {
String translatedResult = Translator.INSTANCE.translate("TransactionValidity", request.getLocale().getLanguage(), result.name());
return ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID, null, translatedResult);
return ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID, null, translatedResult, result.name());
}
}

View File

@@ -137,19 +137,18 @@ public class BlockMinter extends Thread {
// Disregard peers that have "misbehaved" recently
peers.removeIf(Controller.hasMisbehaved);
// Don't mint if we don't have enough connected peers as where would the transactions/consensus come from?
if (peers.size() < Settings.getInstance().getMinBlockchainPeers())
continue;
// Disregard peers that don't have a recent block
peers.removeIf(Controller.hasNoRecentBlock);
// If we have any peers with a recent block, but our latest block isn't recent
// then we need to synchronize instead of minting.
// Don't mint if we don't have enough up-to-date peers as where would the transactions/consensus come from?
if (peers.size() < Settings.getInstance().getMinBlockchainPeers())
continue;
// If our latest block isn't recent then we need to synchronize instead of minting.
if (!peers.isEmpty() && lastBlockData.getTimestamp() < minLatestBlockTimestamp)
continue;
// There are no peers with a recent block and/or our latest block is recent
// There are enough peers with a recent block and our latest block is recent
// so go ahead and mint a block if possible.
isMintingPossible = true;

View File

@@ -133,6 +133,9 @@ public class Controller extends Thread {
/** Whether we can mint new blocks, as reported by BlockMinter. */
private volatile boolean isMintingPossible = false;
/** Whether we are attempting to synchronize. */
private volatile boolean isSynchronizing = false;
/** Latest block signatures from other peers that we know are on inferior chains. */
List<ByteArray> inferiorChainSignatures = new ArrayList<>();
@@ -245,6 +248,15 @@ public class Controller extends Thread {
return isStopping;
}
// For API use
public boolean isMintingPossible() {
return this.isMintingPossible;
}
public boolean isSynchronizing() {
return this.isSynchronizing;
}
// Entry point
public static void main(String[] args) {
@@ -497,7 +509,13 @@ public class Controller extends Thread {
int index = new SecureRandom().nextInt(peers.size());
Peer peer = peers.get(index);
isSynchronizing = true;
updateSysTray();
actuallySynchronize(peer, false);
isSynchronizing = false;
requestSysTrayUpdate = true;
}
public SynchronizationResult actuallySynchronize(Peer peer, boolean force) throws InterruptedException {
@@ -601,9 +619,17 @@ public class Controller extends Thread {
String connectionsText = Translator.INSTANCE.translate("SysTray", numberOfPeers != 1 ? "CONNECTIONS" : "CONNECTION");
String heightText = Translator.INSTANCE.translate("SysTray", "BLOCK_HEIGHT");
String mintingText = Translator.INSTANCE.translate("SysTray", isMintingPossible ? "MINTING_ENABLED" : "MINTING_DISABLED");
String tooltip = String.format("%s - %d %s - %s %d", mintingText, numberOfPeers, connectionsText, heightText, height);
String actionKey;
if (isMintingPossible)
actionKey = "MINTING_ENABLED";
else if (isSynchronizing)
actionKey = "SYNCHRONIZING_BLOCKCHAIN";
else
actionKey = "MINTING_DISABLED";
String actionText = Translator.INSTANCE.translate("SysTray", actionKey);
String tooltip = String.format("%s - %d %s - %s %d", actionText, numberOfPeers, connectionsText, heightText, height);
SysTray.getInstance().setToolTipText(tooltip);
}

View File

@@ -3,7 +3,7 @@ package org.qortal.controller;
import java.math.BigInteger;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@@ -93,18 +93,11 @@ public class Synchronizer {
peerHeight, Base58.encode(peersLastBlockSignature), peer.getChainTipData().getLastBlockTimestamp(),
ourInitialHeight, Base58.encode(ourLastBlockSignature), ourLatestBlockData.getTimestamp()));
List<BlockSummaryData> peerBlockSummaries = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight);
if (peerBlockSummaries == null && Controller.isStopping())
return SynchronizationResult.SHUTTING_DOWN;
if (peerBlockSummaries == null) {
LOGGER.info(String.format("Error while trying to find common block with peer %s", peer));
return SynchronizationResult.NO_REPLY;
}
if (peerBlockSummaries.isEmpty()) {
LOGGER.info(String.format("Failure to find common block with peer %s", peer));
return SynchronizationResult.NO_COMMON_BLOCK;
}
List<BlockSummaryData> peerBlockSummaries = new ArrayList<>();
SynchronizationResult findCommonBlockResult = fetchSummariesFromCommonBlock(repository, peer, ourInitialHeight, force, peerBlockSummaries);
if (findCommonBlockResult != SynchronizationResult.OK)
// Logging performed by fetchSummariesFromCommonBlock() above
return findCommonBlockResult;
// First summary is common block
final BlockData commonBlockData = repository.getBlockRepository().fromSignature(peerBlockSummaries.get(0).getSignature());
@@ -132,13 +125,6 @@ public class Synchronizer {
return SynchronizationResult.NOTHING_TO_DO;
}
// If common block is too far behind us then we're on massively different forks so give up.
int minCommonHeight = ourInitialHeight - MAXIMUM_COMMON_DELTA;
if (!force && commonBlockHeight < minCommonHeight) {
LOGGER.info(String.format("Blockchain too divergent with peer %s", peer));
return SynchronizationResult.TOO_DIVERGENT;
}
// Unless we're doing a forced sync, we might need to compare blocks after common block
if (!force && ourInitialHeight > commonBlockHeight) {
// If our latest block is very old, we're very behind and should ditch our fork.
@@ -332,49 +318,59 @@ public class Synchronizer {
* @throws DataException
* @throws InterruptedException
*/
private List<BlockSummaryData> fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight) throws DataException, InterruptedException {
private SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List<BlockSummaryData> blockSummariesFromCommon) throws DataException, InterruptedException {
// Start by asking for a few recent block hashes as this will cover a majority of reorgs
// Failing that, back off exponentially
int step = INITIAL_BLOCK_STEP;
List<BlockSummaryData> blockSummaries = null;
int testHeight = Math.max(ourHeight - step, 1);
BlockData testBlockData = null;
List<BlockSummaryData> blockSummariesBatch = null;
while (testHeight >= 1) {
// Are we shutting down?
if (Controller.isStopping())
return null;
return SynchronizationResult.SHUTTING_DOWN;
// Fetch our block signature at this height
testBlockData = repository.getBlockRepository().fromHeight(testHeight);
if (testBlockData == null) {
// Not found? But we've locked the blockchain and height is below blockchain's tip!
LOGGER.error("Failed to get block at height lower than blockchain tip during synchronization?");
return null;
return SynchronizationResult.REPOSITORY_ISSUE;
}
// Ask for block signatures since test block's signature
byte[] testSignature = testBlockData.getSignature();
LOGGER.trace(String.format("Requesting %d summar%s after height %d", step, (step != 1 ? "ies": "y"), testHeight));
blockSummaries = this.getBlockSummaries(peer, testSignature, step);
blockSummariesBatch = this.getBlockSummaries(peer, testSignature, step);
if (blockSummaries == null)
if (blockSummariesBatch == null) {
// No response - give up this time
return null;
LOGGER.info(String.format("Error while trying to find common block with peer %s", peer));
return SynchronizationResult.NO_REPLY;
}
LOGGER.trace(String.format("Received %s summar%s", blockSummaries.size(), (blockSummaries.size() != 1 ? "ies" : "y")));
LOGGER.trace(String.format("Received %s summar%s", blockSummariesBatch.size(), (blockSummariesBatch.size() != 1 ? "ies" : "y")));
// Empty list means remote peer is unaware of test signature OR has no new blocks after test signature
if (!blockSummaries.isEmpty())
if (!blockSummariesBatch.isEmpty())
// We have entries so we have found a common block
break;
// No blocks after genesis block?
// We don't get called for a peer at genesis height so this means NO blocks in common
if (testHeight == 1)
return Collections.emptyList();
if (testHeight == 1) {
LOGGER.info(String.format("Failure to find common block with peer %s", peer));
return SynchronizationResult.NO_COMMON_BLOCK;
}
// If common block is too far behind us then we're on massively different forks so give up.
if (!force && testHeight < ourHeight - MAXIMUM_COMMON_DELTA) {
LOGGER.info(String.format("Blockchain too divergent with peer %s", peer));
return SynchronizationResult.TOO_DIVERGENT;
}
if (peer.getVersion() >= 2) {
step <<= 1;
@@ -389,20 +385,21 @@ public class Synchronizer {
// Prepend test block's summary as first block summary, as summaries returned are *after* test block
BlockSummaryData testBlockSummary = new BlockSummaryData(testBlockData);
blockSummaries.add(0, testBlockSummary);
blockSummariesFromCommon.add(0, testBlockSummary);
blockSummariesFromCommon.addAll(blockSummariesBatch);
// Trim summaries so that first summary is common block.
// Currently we work back from the end until we hit a block we also have.
// TODO: rewrite as modified binary search!
for (int i = blockSummaries.size() - 1; i > 0; --i) {
if (repository.getBlockRepository().exists(blockSummaries.get(i).getSignature())) {
for (int i = blockSummariesFromCommon.size() - 1; i > 0; --i) {
if (repository.getBlockRepository().exists(blockSummariesFromCommon.get(i).getSignature())) {
// Note: index i isn't cleared: List.subList is fromIndex inclusive to toIndex exclusive
blockSummaries.subList(0, i).clear();
blockSummariesFromCommon.subList(0, i).clear();
break;
}
}
return blockSummaries;
return SynchronizationResult.OK;
}
private List<BlockSummaryData> getBlockSummaries(Peer peer, byte[] parentSignature, int numberRequested) throws InterruptedException {

View File

@@ -56,6 +56,7 @@ import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.settings.Settings;
import org.qortal.utils.ExecuteProduceConsume;
import org.qortal.utils.ExecuteProduceConsume.StatsSnapshot;
import org.qortal.utils.NTP;
// For managing peers
@@ -199,6 +200,10 @@ public class Network {
return this.maxMessageSize;
}
public StatsSnapshot getStatsSnapshot() {
return this.networkEPC.getStatsSnapshot();
}
// Peer lists
public List<Peer> getConnectedPeers() {

View File

@@ -464,7 +464,7 @@ public class Peer {
}
/* package */ void startPings() {
// Replacing initial null value allows pingCheck() to start sending pings.
// Replacing initial null value allows getPingTask() to start sending pings.
LOGGER.trace(() -> String.format("Enabling pings for peer %s", this));
this.lastPingSent = System.currentTimeMillis();
}
@@ -489,6 +489,7 @@ public class Peer {
final long after = System.currentTimeMillis();
if (message == null || message.getType() != MessageType.PING) {
LOGGER.debug(() -> String.format("Didn't receive reply from %s for PING ID %d", this, pingMessage.getId()));
this.disconnect("no ping received");
return;
}

View File

@@ -4,7 +4,6 @@ import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.qortal.asset.Asset;

View File

@@ -5,11 +5,26 @@ import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public abstract class ExecuteProduceConsume implements Runnable {
@XmlAccessorType(XmlAccessType.FIELD)
public static class StatsSnapshot {
public int activeThreadCount = 0;
public int greatestActiveThreadCount = 0;
public int consumerCount = 0;
public int tasksProduced = 0;
public int tasksConsumed = 0;
public StatsSnapshot() {
}
}
private final String className;
private final Logger logger;
@@ -51,28 +66,18 @@ public abstract class ExecuteProduceConsume implements Runnable {
return this.executor.awaitTermination(timeout, TimeUnit.MILLISECONDS);
}
public int getActiveThreadCount() {
synchronized (this) {
return this.activeThreadCount;
}
}
public StatsSnapshot getStatsSnapshot() {
StatsSnapshot snapshot = new StatsSnapshot();
public int getGreatestActiveThreadCount() {
synchronized (this) {
return this.greatestActiveThreadCount;
snapshot.activeThreadCount = this.activeThreadCount;
snapshot.greatestActiveThreadCount = this.greatestActiveThreadCount;
snapshot.consumerCount = this.consumerCount;
snapshot.tasksProduced = this.tasksProduced;
snapshot.tasksConsumed = this.tasksConsumed;
}
}
public int getTasksProduced() {
synchronized (this) {
return this.tasksProduced;
}
}
public int getTasksConsumed() {
synchronized (this) {
return this.tasksConsumed;
}
return snapshot;
}
/**

View File

@@ -48,7 +48,7 @@ PUBLIC_KEY_NOT_FOUND = public key not found
REPOSITORY_ISSUE = repository error
TRANSACTION_INVALID = transaction invalid: %s
TRANSACTION_INVALID = transaction invalid: %s (%s)
TRANSACTION_UNKNOWN = transaction unknown

View File

@@ -26,4 +26,6 @@ OPEN_UI = Open UI
SYNCHRONIZE_CLOCK = Synchronize clock
SYNCHRONIZING_BLOCKCHAIN = Synchronizing
SYNCHRONIZING_CLOCK = Synchronizing clock

View File

@@ -26,4 +26,6 @@ OPEN_UI = \u5F00\u542F\u754C\u9762
SYNCHRONIZE_CLOCK = \u540C\u6B65\u65F6\u949F
SYNCHRONIZING_BLOCKCHAIN = \u540C\u6B65\u533A\u5757\u94FE
SYNCHRONIZING_CLOCK = \u540C\u6B65\u7740\u65F6\u949F

View File

@@ -1,4 +1,6 @@
ACCOUNT_ALREADY_EXISTS = account already exists
ACCOUNT_CANNOT_REWARD_SHARE = account cannot reward-share
ALREADY_GROUP_ADMIN = already group admin
@@ -139,7 +141,7 @@ NOT_YET_RELEASED = NOT_YET_RELEASED
NO_BALANCE = NO_BALANCE
NO_BLOCKCHAIN_LOCK = NO_BLOCKCHAIN_LOCK
NO_BLOCKCHAIN_LOCK = node's blockchain currently busy
NO_FLAG_PERMISSION = NO_FLAG_PERMISSION

View File

@@ -11,6 +11,7 @@ import java.util.concurrent.TimeUnit;
import org.junit.Test;
import org.qortal.utils.ExecuteProduceConsume;
import org.qortal.utils.ExecuteProduceConsume.StatsSnapshot;
public class EPCTests {
@@ -60,13 +61,12 @@ public class EPCTests {
ScheduledExecutorService statusExecutor = Executors.newSingleThreadScheduledExecutor();
statusExecutor.scheduleAtFixedRate(() -> {
synchronized (testEPC) {
final long seconds = (System.currentTimeMillis() - start) / 1000L;
System.out.println(String.format("After %d second%s, active threads: %d, greatest thread count: %d, tasks produced: %d, tasks consumed: %d",
seconds, (seconds != 1 ? "s" : ""),
testEPC.getActiveThreadCount(), testEPC.getGreatestActiveThreadCount(),
testEPC.getTasksProduced(), testEPC.getTasksConsumed()));
}
StatsSnapshot snapshot = testEPC.getStatsSnapshot();
final long seconds = (System.currentTimeMillis() - start) / 1000L;
System.out.println(String.format("After %d second%s, active threads: %d, greatest thread count: %d, tasks produced: %d, tasks consumed: %d",
seconds, (seconds != 1 ? "s" : ""),
snapshot.activeThreadCount, snapshot.greatestActiveThreadCount,
snapshot.tasksProduced, snapshot.tasksConsumed));
}, 1L, 1L, TimeUnit.SECONDS);
// Let it run for a minute
@@ -78,10 +78,10 @@ public class EPCTests {
final long after = System.currentTimeMillis();
System.out.println(String.format("Shutdown took %d milliseconds", after - before));
System.out.println(String.format("Greatest thread count: %d", testEPC.getGreatestActiveThreadCount()));
System.out.println(String.format("Tasks produced: %d", testEPC.getTasksProduced()));
System.out.println(String.format("Tasks consumed: %d", testEPC.getTasksConsumed()));
StatsSnapshot snapshot = testEPC.getStatsSnapshot();
System.out.println(String.format("Greatest thread count: %d, tasks produced: %d, tasks consumed: %d",
snapshot.greatestActiveThreadCount, snapshot.tasksProduced, snapshot.tasksConsumed));
}
@Test

View File

@@ -14,7 +14,8 @@ public class CheckTranslations {
private static final String[] SUPPORTED_LANGS = new String[] { "en", "de", "zh", "ru" };
private static final Set<String> SYSTRAY_KEYS = Set.of("BLOCK_HEIGHT", "CHECK_TIME_ACCURACY", "CONNECTION", "CONNECTIONS",
"EXIT", "MINTING_DISABLED", "MINTING_ENABLED", "OPEN_UI", "SYNCHRONIZE_CLOCK", "SYNCHRONIZING_CLOCK");
"EXIT", "MINTING_DISABLED", "MINTING_ENABLED", "NTP_NAG_CAPTION", "NTP_NAG_TEXT_UNIX", "NTP_NAG_TEXT_WINDOWS",
"OPEN_UI", "SYNCHRONIZE_CLOCK", "SYNCHRONIZING_BLOCKCHAIN", "SYNCHRONIZING_CLOCK");
private static String failurePrefix;