From 43055b666f474a259ab77ef27bda05d18b3e37ee Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 21 Sep 2020 16:29:34 +0100 Subject: [PATCH 01/55] Improve SQL prepared statement caching in HSQLDBAccountRepository.getEligibleLegacyQoraHolders() --- .../qortal/repository/hsqldb/HSQLDBAccountRepository.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java index 418a8493..0dca46eb 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java @@ -932,6 +932,8 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public List getEligibleLegacyQoraHolders(Integer blockHeight) throws DataException { StringBuilder sql = new StringBuilder(1024); + List bindParams = new ArrayList<>(); + sql.append("SELECT account, Qora.balance, QortFromQora.balance, final_qort_from_qora, final_block_height "); sql.append("FROM AccountBalances AS Qora "); sql.append("LEFT OUTER JOIN AccountQortFromQoraInfo USING (account) "); @@ -942,15 +944,15 @@ public class HSQLDBAccountRepository implements AccountRepository { sql.append(" AND (final_block_height IS NULL"); if (blockHeight != null) { - sql.append(" OR final_block_height >= "); - sql.append(blockHeight); + sql.append(" OR final_block_height >= ?"); + bindParams.add(blockHeight); } sql.append(")"); List eligibleLegacyQoraHolders = new ArrayList<>(); - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return eligibleLegacyQoraHolders; From f3e1092dd54ca2f34f52fa3997e7dc637d0cf4ae Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 21 Sep 2020 16:38:45 +0100 Subject: [PATCH 02/55] Improve SQL prepared statement caching in HSQLDBBlockRepository.getBlockInfos & test to cover --- .../hsqldb/HSQLDBBlockRepository.java | 30 +++++++++---------- .../org/qortal/test/api/BlockApiTests.java | 14 +++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index c9d9de50..563148fd 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -382,6 +382,8 @@ public class HSQLDBBlockRepository implements BlockRepository { @Override public List getBlockInfos(Integer startHeight, Integer endHeight, Integer count) throws DataException { StringBuilder sql = new StringBuilder(512); + List bindParams = new ArrayList<>(); + sql.append("SELECT signature, height, minted_when, transaction_count, RewardShares.minter "); /* @@ -400,10 +402,9 @@ public class HSQLDBBlockRepository implements BlockRepository { if (startHeight != null && endHeight != null) { sql.append("FROM Blocks "); sql.append("JOIN RewardShares ON RewardShares.reward_share_public_key = Blocks.minter "); - sql.append("WHERE height BETWEEN "); - sql.append(startHeight); - sql.append(" AND "); - sql.append(endHeight - 1); + sql.append("WHERE height BETWEEN ? AND ?"); + bindParams.add(startHeight); + bindParams.add(Integer.valueOf(endHeight - 1)); } else if (endHeight != null || (startHeight == null && count != null)) { // we are going to return blocks from the end of the chain if (count == null) @@ -411,17 +412,15 @@ public class HSQLDBBlockRepository implements BlockRepository { if (endHeight == null) { sql.append("FROM (SELECT height FROM Blocks ORDER BY height DESC LIMIT 1) AS MaxHeights (max_height) "); - sql.append("JOIN Blocks ON height BETWEEN (max_height - "); - sql.append(count); - sql.append(" + 1) AND max_height "); + sql.append("JOIN Blocks ON height BETWEEN (max_height - ? + 1) AND max_height "); sql.append("JOIN RewardShares ON RewardShares.reward_share_public_key = Blocks.minter"); + bindParams.add(count); } else { sql.append("FROM Blocks "); sql.append("JOIN RewardShares ON RewardShares.reward_share_public_key = Blocks.minter "); - sql.append("WHERE height BETWEEN "); - sql.append(endHeight - count); - sql.append(" AND "); - sql.append(endHeight - 1); + sql.append("WHERE height BETWEEN ? AND ?"); + bindParams.add(Integer.valueOf(endHeight - count)); + bindParams.add(Integer.valueOf(endHeight - 1)); } } else { @@ -434,15 +433,14 @@ public class HSQLDBBlockRepository implements BlockRepository { sql.append("FROM Blocks "); sql.append("JOIN RewardShares ON RewardShares.reward_share_public_key = Blocks.minter "); - sql.append("WHERE height BETWEEN "); - sql.append(startHeight); - sql.append(" AND "); - sql.append(startHeight + count - 1); + sql.append("WHERE height BETWEEN ? AND ?"); + bindParams.add(startHeight); + bindParams.add(Integer.valueOf(startHeight + count - 1)); } List blockInfos = new ArrayList<>(); - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString())) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return blockInfos; diff --git a/src/test/java/org/qortal/test/api/BlockApiTests.java b/src/test/java/org/qortal/test/api/BlockApiTests.java index 384c9858..a664fa8b 100644 --- a/src/test/java/org/qortal/test/api/BlockApiTests.java +++ b/src/test/java/org/qortal/test/api/BlockApiTests.java @@ -9,6 +9,7 @@ import java.util.List; import org.junit.Before; import org.junit.Test; import org.qortal.account.PrivateKeyAccount; +import org.qortal.api.ApiError; import org.qortal.api.resource.BlocksResource; import org.qortal.block.GenesisBlock; import org.qortal.repository.DataException; @@ -82,6 +83,19 @@ public class BlockApiTests extends ApiCommon { @Test public void testGetBlockRange() { assertNotNull(this.blocksResource.getBlockRange(1, 1)); + + List testValues = Arrays.asList(null, Integer.valueOf(1)); + + for (Integer startHeight : testValues) + for (Integer endHeight : testValues) + for (Integer count : testValues) { + if (startHeight != null && endHeight != null && count != null) { + assertApiError(ApiError.INVALID_CRITERIA, () -> this.blocksResource.getBlockRange(startHeight, endHeight, count)); + continue; + } + + assertNotNull(this.blocksResource.getBlockRange(startHeight, endHeight, count)); + } } @Test From 4209cc6ee4b0f4558874b892c687fb1156f01e88 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 21 Sep 2020 16:42:43 +0100 Subject: [PATCH 03/55] Improve SQL prepared statement caching in HSQLDBATRepository, plus missing space in SQL in getMatchingFinalATStates --- .../repository/hsqldb/HSQLDBATRepository.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index bc26ab78..f8b6ce4d 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -137,16 +137,21 @@ public class HSQLDBATRepository implements ATRepository { @Override public List getATsByFunctionality(byte[] codeHash, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); + List bindParams = new ArrayList<>(); + sql.append("SELECT AT_address, creator, created_when, version, asset_id, code_bytes, ") .append("is_sleeping, sleep_until_height, is_finished, had_fatal_error, ") .append("is_frozen, frozen_balance ") .append("FROM ATs ") .append("WHERE code_hash = ? "); + bindParams.add(codeHash); - if (isExecutable != null) - sql.append("AND is_finished = ").append(isExecutable ? "false" : "true"); + if (isExecutable != null) { + sql.append("AND is_finished = ? "); + bindParams.add(isExecutable); + } - sql.append(" ORDER BY created_when "); + sql.append("ORDER BY created_when "); if (reverse != null && reverse) sql.append("DESC"); @@ -154,7 +159,7 @@ public class HSQLDBATRepository implements ATRepository { List matchingATs = new ArrayList<>(); - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), codeHash)) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return matchingATs; @@ -296,6 +301,8 @@ public class HSQLDBATRepository implements ATRepository { Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(1024); + List bindParams = new ArrayList<>(); + sql.append("SELECT AT_address, height, created_when, state_data, state_hash, fees, is_initial " + "FROM ATs " + "CROSS JOIN LATERAL(" @@ -304,18 +311,16 @@ public class HSQLDBATRepository implements ATRepository { + "WHERE ATStates.AT_address = ATs.AT_address "); if (minimumFinalHeight != null) { - sql.append("AND height >= "); - sql.append(minimumFinalHeight); + sql.append("AND height >= ? "); + bindParams.add(minimumFinalHeight); } // AT_address then height so the compound primary key is used as an index // Both must be the same direction also - sql.append( "ORDER BY AT_address DESC, height DESC " + sql.append("ORDER BY AT_address DESC, height DESC " + "LIMIT 1 " + ") AS FinalATStates " + "WHERE code_hash = ? "); - - List bindParams = new ArrayList<>(); bindParams.add(codeHash); if (isFinished != null) { From d0da5d7c48211cdb7a525d09b5d393191edb62e9 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 24 Sep 2020 12:42:44 +0100 Subject: [PATCH 04/55] ATs: only call MachineState.getCodeBytes() once in preparation for using newer AT lib --- src/main/java/org/qortal/at/AT.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/at/AT.java b/src/main/java/org/qortal/at/AT.java index 5aba1b73..bae1593a 100644 --- a/src/main/java/org/qortal/at/AT.java +++ b/src/main/java/org/qortal/at/AT.java @@ -51,9 +51,10 @@ public class AT { MachineState machineState = new MachineState(api, loggerFactory, deployATTransactionData.getCreationBytes()); - byte[] codeHash = Crypto.digest(machineState.getCodeBytes()); + byte[] codeBytes = machineState.getCodeBytes(); + byte[] codeHash = Crypto.digest(codeBytes); - this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, machineState.getCodeBytes(), codeHash, + this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, codeBytes, codeHash, machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(), machineState.isFrozen(), machineState.getFrozenBalance()); From 21f48fba5f76b984931fce61b1b340bdd1eb059c Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 24 Sep 2020 12:45:37 +0100 Subject: [PATCH 05/55] HSQLDB PreparedStatement caching improvements --- .../repository/hsqldb/HSQLDBRepository.java | 79 +++++++++++-------- .../qortal/repository/hsqldb/HSQLDBSaver.java | 4 +- 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 43c480c6..35ce94db 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -446,10 +446,21 @@ public class HSQLDBRepository implements Repository { * * See org.hsqldb.StatementManager for more details. */ - if (!this.preparedStatementCache.containsKey(sql)) - this.preparedStatementCache.put(sql, this.connection.prepareStatement(sql)); + PreparedStatement preparedStatement = this.preparedStatementCache.get(sql); + if (preparedStatement == null || preparedStatement.isClosed()) { + if (preparedStatement != null) + // This shouldn't occur, so log, but recompile + LOGGER.debug(() -> String.format("Recompiling closed PreparedStatement: %s", sql)); - return this.connection.prepareStatement(sql); + preparedStatement = this.connection.prepareStatement(sql); + this.preparedStatementCache.put(sql, preparedStatement); + } else { + // Clean up ready for reuse + preparedStatement.clearBatch(); + preparedStatement.clearParameters(); + } + + return preparedStatement; } /** @@ -465,9 +476,8 @@ public class HSQLDBRepository implements Repository { public ResultSet checkedExecute(String sql, Object... objects) throws SQLException { PreparedStatement preparedStatement = this.prepareStatement(sql); - // Close the PreparedStatement when the ResultSet is closed otherwise there's a potential resource leak. - // We can't use try-with-resources here as closing the PreparedStatement on return would also prematurely close the ResultSet. - preparedStatement.closeOnCompletion(); + // We don't close the PreparedStatement when the ResultSet is closed because we cached PreparedStatements now. + // They are cleaned up when connection/session is closed. long beforeQuery = this.slowQueryThreshold == null ? 0 : System.currentTimeMillis(); @@ -556,36 +566,35 @@ public class HSQLDBRepository implements Repository { if (batchedObjects == null || batchedObjects.isEmpty()) return 0; - try (PreparedStatement preparedStatement = this.prepareStatement(sql)) { - for (Object[] objects : batchedObjects) { - this.bindStatementParams(preparedStatement, objects); - preparedStatement.addBatch(); - } - - long beforeQuery = this.slowQueryThreshold == null ? 0 : System.currentTimeMillis(); - - int[] updateCounts = preparedStatement.executeBatch(); - - if (this.slowQueryThreshold != null) { - long queryTime = System.currentTimeMillis() - beforeQuery; - - if (queryTime > this.slowQueryThreshold) { - LOGGER.info(() -> String.format("HSQLDB query took %d ms: %s", queryTime, sql), new SQLException("slow query")); - - logStatements(); - } - } - - int totalCount = 0; - for (int i = 0; i < updateCounts.length; ++i) { - if (updateCounts[i] < 0) - throw new SQLException("Database returned invalid row count"); - - totalCount += updateCounts[i]; - } - - return totalCount; + PreparedStatement preparedStatement = this.prepareStatement(sql); + for (Object[] objects : batchedObjects) { + this.bindStatementParams(preparedStatement, objects); + preparedStatement.addBatch(); } + + long beforeQuery = this.slowQueryThreshold == null ? 0 : System.currentTimeMillis(); + + int[] updateCounts = preparedStatement.executeBatch(); + + if (this.slowQueryThreshold != null) { + long queryTime = System.currentTimeMillis() - beforeQuery; + + if (queryTime > this.slowQueryThreshold) { + LOGGER.info(() -> String.format("HSQLDB query took %d ms: %s", queryTime, sql), new SQLException("slow query")); + + logStatements(); + } + } + + int totalCount = 0; + for (int i = 0; i < updateCounts.length; ++i) { + if (updateCounts[i] < 0) + throw new SQLException("Database returned invalid row count"); + + totalCount += updateCounts[i]; + } + + return totalCount; } /** diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java index 8384c22f..c1b6ee9b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBSaver.java @@ -60,7 +60,9 @@ public class HSQLDBSaver { */ public boolean execute(HSQLDBRepository repository) throws SQLException { String sql = this.formatInsertWithPlaceholders(); - try (PreparedStatement preparedStatement = repository.prepareStatement(sql)) { + + try { + PreparedStatement preparedStatement = repository.prepareStatement(sql); this.bindValues(preparedStatement); return preparedStatement.execute(); From 3d5fec3c30c3df12e3bb4109733d67e7ab229ca3 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 25 Sep 2020 15:25:15 +0100 Subject: [PATCH 06/55] Bump to HSQLDB v2.5.1 as we seem clear of OOM issue --- pom.xml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index 2a73ff0e..ddb04109 100644 --- a/pom.xml +++ b/pom.xml @@ -14,8 +14,7 @@ 1.8 1.2.2 28.1-jre - 2.5.0-fixed - 2.5.0 + 2.5.1 2.29.1 9.4.29.v20200521 2.12.1 @@ -397,12 +396,6 @@ hsqldb ${hsqldb.version} - - org.hsqldb - sqltool - ${hsqldb-sqltool.version} - test - org.ciyam From 17ae7acc6df73c1da54bdd8b6b580a7e9759f0c0 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 25 Sep 2020 15:25:57 +0100 Subject: [PATCH 07/55] Reduce DB storage of AT states Drop created_when column from ATStates as it never changes and can be fetched from ATs table. This takes about 50s on a fast machine. Correspondingly rebuild height-based index on ATStates. This takes about 3 minutes on a fast machine. Modify AT-related repository methods and callers. Aggressively remove 'old' (> 2 weeks) actual AT state binary data, leaving only the hash in DB (for syncing purposes). Seems to keep up with syncing from another node on localhost. --- .../api/websocket/TradeOffersWebSocket.java | 2 +- src/main/java/org/qortal/at/AT.java | 5 +- .../org/qortal/controller/Controller.java | 50 +++++++++++ .../java/org/qortal/crosschain/BTCACCT.java | 10 +-- .../java/org/qortal/data/at/ATStateData.java | 14 +-- .../org/qortal/repository/ATRepository.java | 6 ++ .../repository/hsqldb/HSQLDBATRepository.java | 89 +++++++++++++------ .../hsqldb/HSQLDBDatabaseUpdates.java | 12 +++ .../java/org/qortal/settings/Settings.java | 6 ++ 9 files changed, 149 insertions(+), 45 deletions(-) diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 740d7f5d..a2cf3cac 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -256,7 +256,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING) // We want when trade was created, not when it was last updated - atStateTimestamp = atState.getCreation(); + atStateTimestamp = crossChainTradeData.creationTimestamp; else atStateTimestamp = timestamp != null ? timestamp : repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); diff --git a/src/main/java/org/qortal/at/AT.java b/src/main/java/org/qortal/at/AT.java index bae1593a..e82ab14e 100644 --- a/src/main/java/org/qortal/at/AT.java +++ b/src/main/java/org/qortal/at/AT.java @@ -61,7 +61,7 @@ public class AT { byte[] stateData = machineState.toBytes(); byte[] stateHash = Crypto.digest(stateData); - this.atStateData = new ATStateData(atAddress, height, creation, stateData, stateHash, 0L, true); + this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true); } // Getters / setters @@ -107,12 +107,11 @@ public class AT { throw new DataException(String.format("Uncaught exception while running AT '%s'", atAddress), e); } - long creation = this.atData.getCreation(); byte[] stateData = state.toBytes(); byte[] stateHash = Crypto.digest(stateData); long atFees = api.calcFinalFees(state); - this.atStateData = new ATStateData(atAddress, blockHeight, creation, stateData, stateHash, atFees, false); + this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false); return api.getTransactions(); } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 4e9c6e76..2683fb2d 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -111,6 +111,8 @@ public class Controller extends Thread { private static final long NTP_PRE_SYNC_CHECK_PERIOD = 5 * 1000L; // ms private static final long NTP_POST_SYNC_CHECK_PERIOD = 5 * 60 * 1000L; // ms private static final long DELETE_EXPIRED_INTERVAL = 5 * 60 * 1000L; // ms + private static final long TRIM_AT_STATES_INTERVAL = 2 * 1000L; // ms + private static final int TRIM_AT_BATCH_SIZE = 200; // blocks // To do with online accounts list private static final long ONLINE_ACCOUNTS_TASKS_INTERVAL = 10 * 1000L; // ms @@ -138,6 +140,8 @@ public class Controller extends Thread { private long repositoryBackupTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms private long deleteExpiredTimestamp = startTime + DELETE_EXPIRED_INTERVAL; // ms + private long trimAtStatesTimestamp = startTime + TRIM_AT_STATES_INTERVAL; // ms + private Integer trimAtStatesStartHeight = null; private long onlineAccountsTasksTimestamp = startTime + ONLINE_ACCOUNTS_TASKS_INTERVAL; // ms /** Whether we can mint new blocks, as reported by BlockMinter. */ @@ -483,6 +487,11 @@ public class Controller extends Thread { onlineAccountsTasksTimestamp = now + ONLINE_ACCOUNTS_TASKS_INTERVAL; performOnlineAccountsTasks(); } + + if (now >= trimAtStatesTimestamp) { + trimAtStatesTimestamp = now + TRIM_AT_STATES_INTERVAL; + trimAtStates(); + } } } catch (InterruptedException e) { // Fall-through to exit @@ -1427,6 +1436,47 @@ public class Controller extends Thread { } } + private void trimAtStates() { + if (this.getChainTip() == null) + return; + + try (final Repository repository = RepositoryManager.tryRepository()) { + if (repository == null) + return; + + if (trimAtStatesStartHeight == null) { + trimAtStatesStartHeight = repository.getATRepository().findFirstTrimmableStateHeight(); + // The above will probably take enough time by itself + return; + } + + long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); + // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks + long chainTrimmableTimestamp = this.getChainTip().getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + + long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp); + + int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); + int upperBatchHeight = Math.min(trimAtStatesStartHeight + TRIM_AT_BATCH_SIZE, upperTrimmableHeight); + + if (trimAtStatesStartHeight >= upperBatchHeight) + return; + + int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimAtStatesStartHeight, upperBatchHeight); + repository.saveChanges(); + + if (numAtStatesTrimmed > 0) { + LOGGER.debug(() -> String.format("Trimmed %d AT state%s between blocks %d and %d", + numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""), + trimAtStatesStartHeight, upperBatchHeight)); + } else { + trimAtStatesStartHeight = upperBatchHeight; + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to trim AT states: %s", e.getMessage())); + } + } + private void sendOurOnlineAccountsInfo() { final Long now = NTP.getTime(); if (now == null) diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index f3db8587..1e803c52 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -611,7 +611,7 @@ public class BTCACCT { */ public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); - return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); } /** @@ -622,8 +622,8 @@ public class BTCACCT { * @throws DataException */ public static CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { - byte[] creatorPublicKey = repository.getATRepository().getCreatorPublicKey(atStateData.getATAddress()); - return populateTradeData(repository, creatorPublicKey, atStateData); + ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); } /** @@ -633,7 +633,7 @@ public class BTCACCT { * @param atAddress * @throws DataException */ - public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, ATStateData atStateData) throws DataException { + public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException { byte[] addressBytes = new byte[25]; // for general use String atAddress = atStateData.getATAddress(); @@ -641,7 +641,7 @@ public class BTCACCT { tradeData.qortalAtAddress = atAddress; tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); - tradeData.creationTimestamp = atStateData.getCreation(); + tradeData.creationTimestamp = creationTimestamp; Account atAccount = new Account(repository, atAddress); tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); diff --git a/src/main/java/org/qortal/data/at/ATStateData.java b/src/main/java/org/qortal/data/at/ATStateData.java index b8c13e0d..e689f5ae 100644 --- a/src/main/java/org/qortal/data/at/ATStateData.java +++ b/src/main/java/org/qortal/data/at/ATStateData.java @@ -5,7 +5,6 @@ public class ATStateData { // Properties private String ATAddress; private Integer height; - private Long creation; private byte[] stateData; private byte[] stateHash; private Long fees; @@ -14,10 +13,9 @@ public class ATStateData { // Constructors /** Create new ATStateData */ - public ATStateData(String ATAddress, Integer height, Long creation, byte[] stateData, byte[] stateHash, Long fees, boolean isInitial) { + public ATStateData(String ATAddress, Integer height, byte[] stateData, byte[] stateHash, Long fees, boolean isInitial) { this.ATAddress = ATAddress; this.height = height; - this.creation = creation; this.stateData = stateData; this.stateHash = stateHash; this.fees = fees; @@ -26,21 +24,21 @@ public class ATStateData { /** For recreating per-block ATStateData from repository where not all info is needed */ public ATStateData(String ATAddress, int height, byte[] stateHash, Long fees, boolean isInitial) { - this(ATAddress, height, null, null, stateHash, fees, isInitial); + this(ATAddress, height, null, stateHash, fees, isInitial); } /** For creating ATStateData from serialized bytes when we don't have all the info */ public ATStateData(String ATAddress, byte[] stateHash) { // This won't ever be initial AT state from deployment as that's never serialized over the network, // but generated when the DeployAtTransaction is processed locally. - this(ATAddress, null, null, null, stateHash, null, false); + this(ATAddress, null, null, stateHash, null, false); } /** For creating ATStateData from serialized bytes when we don't have all the info */ public ATStateData(String ATAddress, byte[] stateHash, Long fees) { // This won't ever be initial AT state from deployment as that's never serialized over the network, // but generated when the DeployAtTransaction is processed locally. - this(ATAddress, null, null, null, stateHash, fees, false); + this(ATAddress, null, null, stateHash, fees, false); } // Getters / setters @@ -58,10 +56,6 @@ public class ATStateData { this.height = height; } - public Long getCreation() { - return this.creation; - } - public byte[] getStateData() { return this.stateData; } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index f3c2b16d..9abe8c3d 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -87,6 +87,12 @@ public interface ATRepository { */ public List getBlockATStatesAtHeight(int height) throws DataException; + /** Returns height of first trimmable AT state, or null if not found. */ + public Integer findFirstTrimmableStateHeight() throws DataException; + + /** Trims non-initial full AT state data between passed heights. Returns number of trimmed rows. */ + public int trimAtStates(int minHeight, int maxHeight) throws DataException; + /** * Save ATStateData into repository. *

diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index f8b6ce4d..6685896c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -248,7 +248,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATStateData getATStateAtHeight(String atAddress, int height) throws DataException { - String sql = "SELECT created_when, state_data, state_hash, fees, is_initial " + String sql = "SELECT state_data, state_hash, fees, is_initial " + "FROM ATStates " + "WHERE AT_address = ? AND height = ? " + "LIMIT 1"; @@ -257,13 +257,12 @@ public class HSQLDBATRepository implements ATRepository { if (resultSet == null) return null; - long created = resultSet.getLong(1); - byte[] stateData = resultSet.getBytes(2); // Actually BLOB - byte[] stateHash = resultSet.getBytes(3); - long fees = resultSet.getLong(4); - boolean isInitial = resultSet.getBoolean(5); + byte[] stateData = resultSet.getBytes(1); // Actually BLOB + byte[] stateHash = resultSet.getBytes(2); + long fees = resultSet.getLong(3); + boolean isInitial = resultSet.getBoolean(4); - return new ATStateData(atAddress, height, created, stateData, stateHash, fees, isInitial); + return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); } catch (SQLException e) { throw new DataException("Unable to fetch AT state from repository", e); } @@ -271,7 +270,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATStateData getLatestATState(String atAddress) throws DataException { - String sql = "SELECT height, created_when, state_data, state_hash, fees, is_initial " + String sql = "SELECT height, state_data, state_hash, fees, is_initial " + "FROM ATStates " + "WHERE AT_address = ? " // AT_address then height so the compound primary key is used as an index @@ -284,13 +283,12 @@ public class HSQLDBATRepository implements ATRepository { return null; int height = resultSet.getInt(1); - long created = resultSet.getLong(2); - byte[] stateData = resultSet.getBytes(3); // Actually BLOB - byte[] stateHash = resultSet.getBytes(4); - long fees = resultSet.getLong(5); - boolean isInitial = resultSet.getBoolean(6); + byte[] stateData = resultSet.getBytes(2); // Actually BLOB + byte[] stateHash = resultSet.getBytes(3); + long fees = resultSet.getLong(4); + boolean isInitial = resultSet.getBoolean(5); - return new ATStateData(atAddress, height, created, stateData, stateHash, fees, isInitial); + return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); } catch (SQLException e) { throw new DataException("Unable to fetch latest AT state from repository", e); } @@ -303,10 +301,10 @@ public class HSQLDBATRepository implements ATRepository { StringBuilder sql = new StringBuilder(1024); List bindParams = new ArrayList<>(); - sql.append("SELECT AT_address, height, created_when, state_data, state_hash, fees, is_initial " + sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial " + "FROM ATs " + "CROSS JOIN LATERAL(" - + "SELECT height, created_when, state_data, state_hash, fees, is_initial " + + "SELECT height, state_data, state_hash, fees, is_initial " + "FROM ATStates " + "WHERE ATStates.AT_address = ATs.AT_address "); @@ -354,13 +352,12 @@ public class HSQLDBATRepository implements ATRepository { do { String atAddress = resultSet.getString(1); int height = resultSet.getInt(2); - long created = resultSet.getLong(3); - byte[] stateData = resultSet.getBytes(4); // Actually BLOB - byte[] stateHash = resultSet.getBytes(5); - long fees = resultSet.getLong(6); - boolean isInitial = resultSet.getBoolean(7); + byte[] stateData = resultSet.getBytes(3); // Actually BLOB + byte[] stateHash = resultSet.getBytes(4); + long fees = resultSet.getLong(5); + boolean isInitial = resultSet.getBoolean(6); - ATStateData atStateData = new ATStateData(atAddress, height, created, stateData, stateHash, fees, isInitial); + ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); atStates.add(atStateData); } while (resultSet.next()); @@ -375,6 +372,7 @@ public class HSQLDBATRepository implements ATRepository { public List getBlockATStatesAtHeight(int height) throws DataException { String sql = "SELECT AT_address, state_hash, fees, is_initial " + "FROM ATStates " + + "LEFT OUTER JOIN ATs USING (AT_address) " + "WHERE height = ? " + "ORDER BY created_when ASC"; @@ -401,18 +399,57 @@ public class HSQLDBATRepository implements ATRepository { return atStates; } + @Override + public Integer findFirstTrimmableStateHeight() throws DataException { + String sql = "SELECT MIN(height) FROM ATStates " + + "WHERE is_initial = FALSE AND state_data IS NOT NULL"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return null; + + int height = resultSet.getInt(1); + if (height == 0 && resultSet.wasNull()) + return null; + + return height; + } catch (SQLException e) { + throw new DataException("Unable to find first trimmable AT state in repository", e); + } + } + + @Override + public int trimAtStates(int minHeight, int maxHeight) throws DataException { + if (minHeight >= maxHeight) + return 0; + + // We're often called so no need to trim all states in one go. + // Limit updates to reduce CPU and memory load. + String sql = "UPDATE ATStates SET state_data = NULL " + + "WHERE is_initial = FALSE " + + "AND state_data IS NOT NULL " + + "AND height BETWEEN ? AND ? " + + "LIMIT 4000"; + + try { + return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to trim AT states in repository", e); + } + } + @Override public void save(ATStateData atStateData) throws DataException { // We shouldn't ever save partial ATStateData - if (atStateData.getCreation() == null || atStateData.getStateHash() == null || atStateData.getHeight() == null) + if (atStateData.getStateHash() == null || atStateData.getHeight() == null) throw new IllegalArgumentException("Refusing to save partial AT state into repository!"); HSQLDBSaver saveHelper = new HSQLDBSaver("ATStates"); saveHelper.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) - .bind("created_when", atStateData.getCreation()).bind("state_data", atStateData.getStateData()) - .bind("state_hash", atStateData.getStateHash()).bind("fees", atStateData.getFees()) - .bind("is_initial", atStateData.isInitial()); + .bind("state_data", atStateData.getStateData()).bind("state_hash", atStateData.getStateHash()) + .bind("fees", atStateData.getFees()).bind("is_initial", atStateData.isInitial()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index c6356e5d..6db40f21 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -212,6 +212,8 @@ public class HSQLDBDatabaseUpdates { + "PRIMARY KEY (account))"); // For looking up an account by public key stmt.execute("CREATE INDEX AccountPublicKeyIndex on Accounts (public_key)"); + // Use a separate table space as this table will be very large. + stmt.execute("SET TABLE Accounts NEW SPACE"); // Account balances stmt.execute("CREATE TABLE AccountBalances (account QortalAddress, asset_id AssetID, balance QortalAmount NOT NULL, " @@ -220,6 +222,8 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX AccountBalancesAssetBalanceIndex ON AccountBalances (asset_id, balance)"); // Add CHECK constraint to account balances stmt.execute("ALTER TABLE AccountBalances ADD CONSTRAINT CheckBalanceNotNegative CHECK (balance >= 0)"); + // Use a separate table space as this table will be very large. + stmt.execute("SET TABLE AccountBalances NEW SPACE"); // Keeping track of QORT gained from holding legacy QORA stmt.execute("CREATE TABLE AccountQortFromQoraInfo (account QortalAddress, final_qort_from_qora QortalAmount, final_block_height INT, " @@ -417,6 +421,8 @@ public class HSQLDBDatabaseUpdates { + "PRIMARY KEY (AT_address, height), FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); // For finding per-block AT states, ordered by creation timestamp stmt.execute("CREATE INDEX BlockATStateIndex on ATStates (height, created_when)"); + // Use a separate table space as this table will be very large. + stmt.execute("SET TABLE ATStates NEW SPACE"); // Deploy CIYAM AT Transactions stmt.execute("CREATE TABLE DeployATTransactions (signature Signature, creator QortalPublicKey NOT NULL, AT_name ATName NOT NULL, " @@ -653,6 +659,12 @@ public class HSQLDBDatabaseUpdates { stmt.execute("DROP TABLE IF EXISTS NextBlockHeight"); break; + case 25: + // Remove excess created_when from ATStates + stmt.execute("ALTER TABLE ATStates DROP created_when"); + stmt.execute("CREATE INDEX ATStateHeightIndex on ATStates (height)"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index b42675c5..38a5f3c6 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -79,6 +79,8 @@ public class Settings { private long repositoryBackupInterval = 0; // ms /** Whether to show a notification when we backup repository. */ private boolean showBackupNotification = false; + /** How long to keep old, full, AT state data (ms). */ + private long atStatesMaxLifetime = 2 * 7 * 24 * 60 * 60 * 1000L; // milliseconds // Peer-to-peer related private boolean isTestNet = false; @@ -406,4 +408,8 @@ public class Settings { return this.showBackupNotification; } + public long getAtStatesMaxLifetime() { + return this.atStatesMaxLifetime; + } + } From a6f42df9d65d4f9866f174783e6aa18eaeb2e8f3 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 25 Sep 2020 16:35:53 +0100 Subject: [PATCH 08/55] Add isTestNet to API call GET /admin/info --- src/main/java/org/qortal/api/model/NodeInfo.java | 1 + src/main/java/org/qortal/api/resource/AdminResource.java | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/api/model/NodeInfo.java b/src/main/java/org/qortal/api/model/NodeInfo.java index 86ed6971..16a4df75 100644 --- a/src/main/java/org/qortal/api/model/NodeInfo.java +++ b/src/main/java/org/qortal/api/model/NodeInfo.java @@ -11,6 +11,7 @@ public class NodeInfo { public String buildVersion; public long buildTimestamp; public String nodeId; + public boolean isTestNet; public NodeInfo() { } diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 7b07551a..e3e2e2a8 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -57,6 +57,7 @@ import org.qortal.network.PeerAddress; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; import org.qortal.utils.Base58; import org.qortal.utils.NTP; @@ -118,6 +119,7 @@ public class AdminResource { nodeInfo.buildVersion = Controller.getInstance().getVersionString(); nodeInfo.buildTimestamp = Controller.getInstance().getBuildTimestamp(); nodeInfo.nodeId = Network.getInstance().getOurNodeId(); + nodeInfo.isTestNet = Settings.getInstance().isTestNet(); return nodeInfo; } From 81a5b154c28aa9cc005cdb6d196a61b0a2cbea1b Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 25 Sep 2020 17:06:06 +0100 Subject: [PATCH 09/55] Add API call DELETE /admin/repository which actually performs repository maintenance (takes several minutes) --- .../qortal/api/resource/AdminResource.java | 32 ++++++++++++++++--- .../repository/hsqldb/HSQLDBRepository.java | 3 ++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index e3e2e2a8..52d7a9e7 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -40,7 +40,6 @@ import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; -import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.ActivitySummary; @@ -437,8 +436,6 @@ public class AdminResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } catch (NumberFormatException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_HEIGHT); - } catch (ApiException e) { - throw e; } } @@ -494,8 +491,6 @@ public class AdminResource { return syncResult.name(); } catch (IllegalArgumentException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - } catch (ApiException e) { - throw e; } catch (UnknownHostException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); } catch (InterruptedException e) { @@ -503,4 +498,31 @@ public class AdminResource { } } + @DELETE + @Path("/repository") + @Operation( + summary = "Perform maintenance on repository.", + description = "Requires enough free space to rebuild repository. This will pause your node for a while." + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public void performRepositoryMaintenance() { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + repository.performPeriodicMaintenance(); + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // No big deal + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 35ce94db..9cf2c07c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -351,7 +351,10 @@ public class HSQLDBRepository implements Repository { public void performPeriodicMaintenance() throws DataException { // Defrag DB - takes a while! try (Statement stmt = this.connection.createStatement()) { + LOGGER.info("performing maintenance - this will take a while"); + stmt.execute("CHECKPOINT"); stmt.execute("CHECKPOINT DEFRAG"); + LOGGER.info("maintenance completed"); } catch (SQLException e) { throw new DataException("Unable to defrag repository"); } From d85a3d17c82e7435a1f2a7d71bbb88181fbf628b Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 28 Sep 2020 14:22:18 +0100 Subject: [PATCH 10/55] Fix for HSQLDB deadlock during CHECKPOINT. Symptoms are: * db/blockchain.log is pretty much exactly 50MB - the checkpoint-triggering size. * Loads of threads are stuck waiting for HSQLDB's CountUpDownLatch$Sync.await() * Synchronizer, or some other thread, possibly orphaning blocks. The cause seems to be method A, which has a repository session, calls EventBus.INSTANCE.notify() and one of the event listeners then obtains their own repository session to do repository 'work'. In the meantime, the HSQLDB log has reached 50MB, triggering auto-checkpoint. HSQLDB attempts to CHECKPOINT, but waits for existing transactions to complete, and also blocks starting new transactions. Thus, one of the event listeners is blocked when they try to obtain a new repository session, but HSQLDB never performs CHECKPOINT because the event notifier (method A) still has an unfinished transaction - hence deadlock. --- src/main/java/org/qortal/block/BlockChain.java | 1 + .../java/org/qortal/controller/BlockMinter.java | 4 +++- .../java/org/qortal/controller/Controller.java | 6 ++++++ .../java/org/qortal/controller/Synchronizer.java | 1 + src/main/java/org/qortal/event/EventBus.java | 15 +++++++++++++++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 8f6ddab7..95ecc41b 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -567,6 +567,7 @@ public class BlockChain { --height; orphanBlockData = repository.getBlockRepository().fromHeight(height); + repository.discardChanges(); // clear transaction status to prevent deadlocks Controller.getInstance().onNewBlock(orphanBlockData); } diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index aa80246d..46a29cf9 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -306,8 +306,10 @@ public class BlockMinter extends Thread { } if (newBlockMinted) { - BlockData newBlockData = newBlock.getBlockData(); // Notify Controller and broadcast our new chain to network + BlockData newBlockData = newBlock.getBlockData(); + + repository.discardChanges(); // clear transaction status to prevent deadlocks Controller.getInstance().onNewBlock(newBlockData); Network network = Network.getInstance(); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 2683fb2d..406fda79 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -835,6 +835,12 @@ public class Controller extends Thread { } } + /** + * Callback for when we've received a new block. + *

+ * See WARNING for {@link EventBus#notify(Event)} + * to prevent deadlocks. + */ public void onNewBlock(BlockData latestBlockData) { // Protective copy BlockData blockDataCopy = new BlockData(latestBlockData); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 8dca5b05..5af2030d 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -410,6 +410,7 @@ public class Synchronizer { --ourHeight; orphanBlockData = repository.getBlockRepository().fromHeight(ourHeight); + repository.discardChanges(); // clear transaction status to prevent deadlocks Controller.getInstance().onNewBlock(orphanBlockData); } diff --git a/src/main/java/org/qortal/event/EventBus.java b/src/main/java/org/qortal/event/EventBus.java index e0014a20..63c80143 100644 --- a/src/main/java/org/qortal/event/EventBus.java +++ b/src/main/java/org/qortal/event/EventBus.java @@ -20,6 +20,21 @@ public enum EventBus { } } + /** + * WARNING: before calling this method, + * make sure repository holds no locks, e.g. by calling + * repository.discardChanges(). + *

+ * This is because event listeners might open a new + * repository session which will deadlock HSQLDB + * if it tries to CHECKPOINT. + *

+ * The HSQLDB deadlock occurs because the caller's + * repository session blocks the CHECKPOINT until + * their transaction is closed, yet event listeners + * new sessions are blocked until CHECKPOINT is + * completed, hence deadlock. + */ public void notify(Event event) { List clonedListeners; From 855cb2226a4e9ea087c08d94ad1573a68c5211e1 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 28 Sep 2020 14:34:00 +0100 Subject: [PATCH 11/55] Aggressively trim old AT state data and online accounts signatures. Two new classes/threads made to quickly find first trimmable row then repeatedly trim rows in small batches after that. --- .../qortal/controller/AtStatesTrimmer.java | 86 +++++++++++++++++++ .../org/qortal/controller/Controller.java | 75 ++-------------- .../OnlineAccountsSignaturesTrimmer.java | 81 +++++++++++++++++ .../org/qortal/repository/ATRepository.java | 8 +- .../qortal/repository/BlockRepository.java | 8 +- .../repository/hsqldb/HSQLDBATRepository.java | 24 +++--- .../hsqldb/HSQLDBBlockRepository.java | 24 +++++- .../java/org/qortal/test/RepositoryTests.java | 2 +- 8 files changed, 213 insertions(+), 95 deletions(-) create mode 100644 src/main/java/org/qortal/controller/AtStatesTrimmer.java create mode 100644 src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java new file mode 100644 index 00000000..d1439aae --- /dev/null +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -0,0 +1,86 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +public class AtStatesTrimmer implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(AtStatesTrimmer.class); + + private enum TrimMode { SEARCHING, TRIMMING } + private static final long TRIM_INTERVAL = 2 * 1000L; // ms + private static final int TRIM_SEARCH_SIZE = 5000; // blocks + private static final int TRIM_BATCH_SIZE = 200; // blocks + private static final int TRIM_LIMIT = 4000; // rows + + private TrimMode trimMode = TrimMode.SEARCHING; + private int trimStartHeight = 0; + + @Override + public void run() { + try (final Repository repository = RepositoryManager.getRepository()) { + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(TRIM_INTERVAL); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; + + long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); + // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks + long chainTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); + + long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp); + int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); + + if (trimMode == TrimMode.SEARCHING) { + int trimEndHeight = Math.min(trimStartHeight + TRIM_SEARCH_SIZE, upperTrimmableHeight); + + LOGGER.debug(() -> String.format("Searching for trimmable AT states between blocks %d and %d", trimStartHeight, trimEndHeight)); + int foundStartHeight = repository.getATRepository().findFirstTrimmableStateHeight(trimStartHeight, trimEndHeight); + + if (foundStartHeight == 0) { + // No trimmable AT states found + trimStartHeight = trimEndHeight; + } else { + trimStartHeight = foundStartHeight; + trimMode = TrimMode.TRIMMING; + LOGGER.debug(() -> String.format("Found first trimmable AT state at block height %d", trimStartHeight)); + } + + // The above search will probably take enough time by itself so wait until next round + continue; + } + + int upperBatchHeight = Math.min(trimStartHeight + TRIM_BATCH_SIZE, upperTrimmableHeight); + + if (trimStartHeight >= upperBatchHeight) + continue; + + int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperBatchHeight, TRIM_LIMIT); + repository.saveChanges(); + + if (numAtStatesTrimmed > 0) { + LOGGER.debug(() -> String.format("Trimmed %d AT state%s between blocks %d and %d", + numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""), + trimStartHeight, upperBatchHeight)); + } else { + trimStartHeight = upperBatchHeight; + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to trim AT states: %s", e.getMessage())); + } catch (InterruptedException e) { + // Time to exit + } + } + +} diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 406fda79..6897751a 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -86,6 +86,7 @@ import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.utils.Base58; import org.qortal.utils.ByteArray; +import org.qortal.utils.DaemonThreadFactory; import org.qortal.utils.NTP; import org.qortal.utils.Triple; @@ -111,8 +112,6 @@ public class Controller extends Thread { private static final long NTP_PRE_SYNC_CHECK_PERIOD = 5 * 1000L; // ms private static final long NTP_POST_SYNC_CHECK_PERIOD = 5 * 60 * 1000L; // ms private static final long DELETE_EXPIRED_INTERVAL = 5 * 60 * 1000L; // ms - private static final long TRIM_AT_STATES_INTERVAL = 2 * 1000L; // ms - private static final int TRIM_AT_BATCH_SIZE = 200; // blocks // To do with online accounts list private static final long ONLINE_ACCOUNTS_TASKS_INTERVAL = 10 * 1000L; // ms @@ -140,8 +139,7 @@ public class Controller extends Thread { private long repositoryBackupTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms private long deleteExpiredTimestamp = startTime + DELETE_EXPIRED_INTERVAL; // ms - private long trimAtStatesTimestamp = startTime + TRIM_AT_STATES_INTERVAL; // ms - private Integer trimAtStatesStartHeight = null; + private long onlineAccountsTasksTimestamp = startTime + ONLINE_ACCOUNTS_TASKS_INTERVAL; // ms /** Whether we can mint new blocks, as reported by BlockMinter. */ @@ -417,6 +415,9 @@ public class Controller extends Thread { final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval(); + Executors.newSingleThreadExecutor(new DaemonThreadFactory("AT states trimmer")).execute(new AtStatesTrimmer()); + Executors.newSingleThreadExecutor(new DaemonThreadFactory("Online sigs trimmer")).execute(new OnlineAccountsSignaturesTrimmer()); + try { while (!isStopping) { // Maybe update SysTray @@ -487,11 +488,6 @@ public class Controller extends Thread { onlineAccountsTasksTimestamp = now + ONLINE_ACCOUNTS_TASKS_INTERVAL; performOnlineAccountsTasks(); } - - if (now >= trimAtStatesTimestamp) { - trimAtStatesTimestamp = now + TRIM_AT_STATES_INTERVAL; - trimAtStates(); - } } } catch (InterruptedException e) { // Fall-through to exit @@ -1420,67 +1416,6 @@ public class Controller extends Thread { // Refresh our online accounts signatures? sendOurOnlineAccountsInfo(); - - // Trim blockchain by removing 'old' online accounts signatures - long upperMintedTimestamp = now - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); - trimOldOnlineAccountsSignatures(upperMintedTimestamp); - } - - private void trimOldOnlineAccountsSignatures(long upperMintedTimestamp) { - try (final Repository repository = RepositoryManager.tryRepository()) { - if (repository == null) - return; - - int numBlocksTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(upperMintedTimestamp); - - if (numBlocksTrimmed > 0) - LOGGER.debug(() -> String.format("Trimmed old online accounts signatures from %d block%s", numBlocksTrimmed, (numBlocksTrimmed != 1 ? "s" : ""))); - - repository.saveChanges(); - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to trim old online accounts signatures: %s", e.getMessage())); - } - } - - private void trimAtStates() { - if (this.getChainTip() == null) - return; - - try (final Repository repository = RepositoryManager.tryRepository()) { - if (repository == null) - return; - - if (trimAtStatesStartHeight == null) { - trimAtStatesStartHeight = repository.getATRepository().findFirstTrimmableStateHeight(); - // The above will probably take enough time by itself - return; - } - - long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); - // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks - long chainTrimmableTimestamp = this.getChainTip().getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); - - long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp); - - int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); - int upperBatchHeight = Math.min(trimAtStatesStartHeight + TRIM_AT_BATCH_SIZE, upperTrimmableHeight); - - if (trimAtStatesStartHeight >= upperBatchHeight) - return; - - int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimAtStatesStartHeight, upperBatchHeight); - repository.saveChanges(); - - if (numAtStatesTrimmed > 0) { - LOGGER.debug(() -> String.format("Trimmed %d AT state%s between blocks %d and %d", - numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""), - trimAtStatesStartHeight, upperBatchHeight)); - } else { - trimAtStatesStartHeight = upperBatchHeight; - } - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue trying to trim AT states: %s", e.getMessage())); - } } private void sendOurOnlineAccountsInfo() { diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java new file mode 100644 index 00000000..139859d8 --- /dev/null +++ b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java @@ -0,0 +1,81 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.block.BlockChain; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.NTP; + +public class OnlineAccountsSignaturesTrimmer implements Runnable { + + private static final Logger LOGGER = LogManager.getLogger(OnlineAccountsSignaturesTrimmer.class); + + private enum TrimMode { SEARCHING, TRIMMING } + private static final long TRIM_INTERVAL = 2 * 1000L; // ms + private static final int TRIM_SEARCH_SIZE = 5000; // blocks + private static final int TRIM_BATCH_SIZE = 500; // blocks + + private TrimMode trimMode = TrimMode.SEARCHING; + private int trimStartHeight = 0; + + public void run() { + try (final Repository repository = RepositoryManager.getRepository()) { + while (!Controller.isStopping()) { + repository.discardChanges(); + + Thread.sleep(TRIM_INTERVAL); + + BlockData chainTip = Controller.getInstance().getChainTip(); + if (chainTip == null || NTP.getTime() == null) + continue; + + // Trim blockchain by removing 'old' online accounts signatures + long upperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); + int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); + + if (trimMode == TrimMode.SEARCHING) { + int trimEndHeight = Math.min(trimStartHeight + TRIM_SEARCH_SIZE, upperTrimmableHeight); + + LOGGER.debug(() -> String.format("Searching for trimmable online accounts signatures between blocks %d and %d", trimStartHeight, trimEndHeight)); + int foundStartHeight = repository.getBlockRepository().findFirstTrimmableOnlineAccountsSignatureHeight(trimStartHeight, trimEndHeight); + + if (foundStartHeight == 0) { + // No trimmable online accounts signatures found + trimStartHeight = trimEndHeight; + } else { + trimStartHeight = foundStartHeight; + trimMode = TrimMode.TRIMMING; + LOGGER.debug(() -> String.format("Found first trimmable online accounts signatures at block height %d", trimStartHeight)); + } + + // The above search will probably take enough time by itself so wait until next round + continue; + } + + int upperBatchHeight = Math.min(trimStartHeight + TRIM_BATCH_SIZE, upperTrimmableHeight); + + if (trimStartHeight >= upperBatchHeight) + continue; + + int numSigsTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(trimStartHeight, upperBatchHeight); + repository.saveChanges(); + + if (numSigsTrimmed > 0) { + LOGGER.debug(() -> String.format("Trimmed %d online accounts signature%s between blocks %d and %d", + numSigsTrimmed, (numSigsTrimmed != 1 ? "s" : ""), + trimStartHeight, upperBatchHeight)); + } else { + trimStartHeight = upperBatchHeight; + } + } + } catch (DataException e) { + LOGGER.warn(String.format("Repository issue trying to trim online accounts signatures: %s", e.getMessage())); + } catch (InterruptedException e) { + // Time to exit + } + } + +} diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 9abe8c3d..509569bc 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -87,11 +87,11 @@ public interface ATRepository { */ public List getBlockATStatesAtHeight(int height) throws DataException; - /** Returns height of first trimmable AT state, or null if not found. */ - public Integer findFirstTrimmableStateHeight() throws DataException; + /** Returns height of first trimmable AT state, or 0 if not found. */ + public int findFirstTrimmableStateHeight(int minHeight, int maxHeight) throws DataException; - /** Trims non-initial full AT state data between passed heights. Returns number of trimmed rows. */ - public int trimAtStates(int minHeight, int maxHeight) throws DataException; + /** Trims full AT state data between passed heights. Returns number of trimmed rows. */ + public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException; /** * Save ATStateData into repository. diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index 4265b71f..bb2caaa1 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -143,13 +143,15 @@ public interface BlockRepository { */ public List getBlockInfos(Integer startHeight, Integer endHeight, Integer count) throws DataException; + /** Returns height of first trimmable online accounts signatures, or 0 if not found. */ + public int findFirstTrimmableOnlineAccountsSignatureHeight(int minHeight, int maxHeight) throws DataException; + /** - * Trim online accounts signatures from blocks older than passed timestamp. + * Trim online accounts signatures from blocks between passed heights. * - * @param timestamp * @return number of blocks trimmed */ - public int trimOldOnlineAccountsSignatures(long timestamp) throws DataException; + public int trimOldOnlineAccountsSignatures(int minHeight, int maxHeight) throws DataException; /** * Returns first (lowest height) block that doesn't link back to specified block. diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 6685896c..f5e54f2a 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -400,39 +400,35 @@ public class HSQLDBATRepository implements ATRepository { } @Override - public Integer findFirstTrimmableStateHeight() throws DataException { + public int findFirstTrimmableStateHeight(int minHeight, int maxHeight) throws DataException { String sql = "SELECT MIN(height) FROM ATStates " - + "WHERE is_initial = FALSE AND state_data IS NOT NULL"; + + "WHERE state_data IS NOT NULL " + + "AND height BETWEEN ? AND ?"; - try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + try (ResultSet resultSet = this.repository.checkedExecute(sql, minHeight, maxHeight)) { if (resultSet == null) - return null; + return 0; - int height = resultSet.getInt(1); - if (height == 0 && resultSet.wasNull()) - return null; - - return height; + return resultSet.getInt(1); } catch (SQLException e) { throw new DataException("Unable to find first trimmable AT state in repository", e); } } @Override - public int trimAtStates(int minHeight, int maxHeight) throws DataException { + public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException { if (minHeight >= maxHeight) return 0; // We're often called so no need to trim all states in one go. // Limit updates to reduce CPU and memory load. String sql = "UPDATE ATStates SET state_data = NULL " - + "WHERE is_initial = FALSE " - + "AND state_data IS NOT NULL " + + "WHERE state_data IS NOT NULL " + "AND height BETWEEN ? AND ? " - + "LIMIT 4000"; + + "LIMIT ?"; try { - return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight); + return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight, limit); } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to trim AT states in repository", e); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index 563148fd..52a6f1d0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -462,13 +462,31 @@ public class HSQLDBBlockRepository implements BlockRepository { } @Override - public int trimOldOnlineAccountsSignatures(long timestamp) throws DataException { + public int findFirstTrimmableOnlineAccountsSignatureHeight(int minHeight, int maxHeight) throws DataException { + String sql = "SELECT MIN(height) FROM Blocks " + + "WHERE online_accounts_signatures IS NOT NULL " + + "AND height BETWEEN ? AND ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, minHeight, maxHeight)) { + if (resultSet == null) + return 0; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to find first trimmable online accounts signatures in repository", e); + } + } + + @Override + public int trimOldOnlineAccountsSignatures(int minHeight, int maxHeight) throws DataException { // We're often called so no need to trim all blocks in one go. // Limit updates to reduce CPU and memory load. - String sql = "UPDATE Blocks set online_accounts_signatures = NULL WHERE minted_when < ? AND online_accounts_signatures IS NOT NULL LIMIT 1440"; + String sql = "UPDATE Blocks SET online_accounts_signatures = NULL " + + "WHERE online_accounts_signatures IS NOT NULL " + + "AND height BETWEEN ? AND ?"; try { - return this.repository.executeCheckedUpdate(sql, timestamp); + return this.repository.executeCheckedUpdate(sql, minHeight, maxHeight); } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to trim old online accounts signatures in repository", e); diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index b453ce7b..d5e70886 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -112,7 +112,7 @@ public class RepositoryTests extends Common { BlockUtils.mintBlock(repository1); // Perform database 'update', but don't commit at this stage - repository1.getBlockRepository().trimOldOnlineAccountsSignatures(System.currentTimeMillis()); + repository1.getBlockRepository().trimOldOnlineAccountsSignatures(1, 10); // Open connection 2 try (final Repository repository2 = RepositoryManager.getRepository()) { From bed9837967e2c7d3009c70b6aae77325db67cd67 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 29 Sep 2020 10:56:27 +0100 Subject: [PATCH 12/55] Added settings entry "localeLang" for controlling core language (not-API) --- src/main/java/org/qortal/globalization/Translator.java | 4 ++-- src/main/java/org/qortal/settings/Settings.java | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/globalization/Translator.java b/src/main/java/org/qortal/globalization/Translator.java index 8f0b6136..6481dde7 100644 --- a/src/main/java/org/qortal/globalization/Translator.java +++ b/src/main/java/org/qortal/globalization/Translator.java @@ -10,12 +10,12 @@ import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.settings.Settings; public enum Translator { INSTANCE; private static final Logger LOGGER = LogManager.getLogger(Translator.class); - private static final String DEFAULT_LANG = Locale.getDefault().getLanguage(); private static final Map resourceBundles = new HashMap<>(); @@ -34,7 +34,7 @@ public enum Translator { } public String translate(String className, String key) { - return this.translate(className, DEFAULT_LANG, key); + return this.translate(className, Settings.getInstance().getLocaleLang(), key); } public Set keySet(String className, String lang) { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 38a5f3c6..9a12e880 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -5,6 +5,7 @@ import java.io.FileNotFoundException; import java.io.FileReader; import java.io.IOException; import java.io.Reader; +import java.util.Locale; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBException; @@ -41,6 +42,9 @@ public class Settings { // Settings, and other config files private String userPath; + // General + private String localeLang = Locale.getDefault().getLanguage(); + // Common to all networking (API/P2P) private String bindAddress = "::"; // Use IPv6 wildcard to listen on all local addresses @@ -261,6 +265,10 @@ public class Settings { return this.userPath; } + public String getLocaleLang() { + return this.localeLang; + } + public int getUiServerPort() { return this.uiPort; } From a681f741dddafc62dbb0b552336dc9544316fba1 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 29 Sep 2020 11:40:41 +0100 Subject: [PATCH 13/55] Add initial delay before trimming online accounts signatures --- .../qortal/controller/OnlineAccountsSignaturesTrimmer.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java index 139859d8..3d51986b 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java @@ -13,6 +13,8 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { private static final Logger LOGGER = LogManager.getLogger(OnlineAccountsSignaturesTrimmer.class); + private static final long INITIAL_SLEEP_PERIOD = 5 * 60 * 1000L; // ms + private enum TrimMode { SEARCHING, TRIMMING } private static final long TRIM_INTERVAL = 2 * 1000L; // ms private static final int TRIM_SEARCH_SIZE = 5000; // blocks @@ -23,6 +25,9 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { public void run() { try (final Repository repository = RepositoryManager.getRepository()) { + // Don't even start trimming until initial rush has ended + Thread.sleep(INITIAL_SLEEP_PERIOD); + while (!Controller.isStopping()) { repository.discardChanges(); From a6a1f65d3ec0fbd339095dbdcd9011bb8db142fb Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 29 Sep 2020 11:41:30 +0100 Subject: [PATCH 14/55] Reduce block search size in AT state trimmer to reduce load --- src/main/java/org/qortal/controller/AtStatesTrimmer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index d1439aae..819ecbaa 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -15,7 +15,7 @@ public class AtStatesTrimmer implements Runnable { private enum TrimMode { SEARCHING, TRIMMING } private static final long TRIM_INTERVAL = 2 * 1000L; // ms - private static final int TRIM_SEARCH_SIZE = 5000; // blocks + private static final int TRIM_SEARCH_SIZE = 2000; // blocks private static final int TRIM_BATCH_SIZE = 200; // blocks private static final int TRIM_LIMIT = 4000; // rows From 60621e8b81f032e61ee0164c8e04051a4b91d743 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 1 Oct 2020 12:47:52 +0100 Subject: [PATCH 15/55] Reworked AT-states and online signatures trimming Instead of searching from block 0, we now keep a record of base trim height in the DB itself. Also, we no longer trim the latest AT state for non-finished ATs in case they are in deep sleeping and we need their state for when they awaken. --- .../qortal/controller/AtStatesTrimmer.java | 53 ++++++++-------- .../org/qortal/controller/Controller.java | 16 ++++- .../OnlineAccountsSignaturesTrimmer.java | 47 ++++++--------- .../org/qortal/repository/ATRepository.java | 10 +++- .../qortal/repository/BlockRepository.java | 7 ++- .../repository/hsqldb/HSQLDBATRepository.java | 60 +++++++++++++++++-- .../hsqldb/HSQLDBBlockRepository.java | 22 +++++-- .../hsqldb/HSQLDBDatabaseUpdates.java | 6 ++ 8 files changed, 145 insertions(+), 76 deletions(-) diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index 819ecbaa..a0db9650 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -13,18 +13,22 @@ public class AtStatesTrimmer implements Runnable { private static final Logger LOGGER = LogManager.getLogger(AtStatesTrimmer.class); - private enum TrimMode { SEARCHING, TRIMMING } private static final long TRIM_INTERVAL = 2 * 1000L; // ms - private static final int TRIM_SEARCH_SIZE = 2000; // blocks - private static final int TRIM_BATCH_SIZE = 200; // blocks - private static final int TRIM_LIMIT = 4000; // rows - private TrimMode trimMode = TrimMode.SEARCHING; - private int trimStartHeight = 0; + // This has a significant effect on execution time + private static final int TRIM_BATCH_SIZE = 200; // blocks + + // Not so significant effect on execution time + private static final int TRIM_LIMIT = 4000; // rows @Override public void run() { + Thread.currentThread().setName("AT States trimmer"); + try (final Repository repository = RepositoryManager.getRepository()) { + repository.getATRepository().prepareForAtStateTrimming(); + repository.saveChanges(); + while (!Controller.isStopping()) { repository.discardChanges(); @@ -41,39 +45,30 @@ public class AtStatesTrimmer implements Runnable { long upperTrimmableTimestamp = Math.min(currentTrimmableTimestamp, chainTrimmableTimestamp); int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); - if (trimMode == TrimMode.SEARCHING) { - int trimEndHeight = Math.min(trimStartHeight + TRIM_SEARCH_SIZE, upperTrimmableHeight); + int trimStartHeight = repository.getATRepository().getAtTrimHeight(); - LOGGER.debug(() -> String.format("Searching for trimmable AT states between blocks %d and %d", trimStartHeight, trimEndHeight)); - int foundStartHeight = repository.getATRepository().findFirstTrimmableStateHeight(trimStartHeight, trimEndHeight); + int upperBatchHeight = trimStartHeight + TRIM_BATCH_SIZE; + int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); - if (foundStartHeight == 0) { - // No trimmable AT states found - trimStartHeight = trimEndHeight; - } else { - trimStartHeight = foundStartHeight; - trimMode = TrimMode.TRIMMING; - LOGGER.debug(() -> String.format("Found first trimmable AT state at block height %d", trimStartHeight)); - } - - // The above search will probably take enough time by itself so wait until next round - continue; - } - - int upperBatchHeight = Math.min(trimStartHeight + TRIM_BATCH_SIZE, upperTrimmableHeight); - - if (trimStartHeight >= upperBatchHeight) + if (trimStartHeight >= upperTrimHeight) continue; - int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperBatchHeight, TRIM_LIMIT); + int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperTrimHeight, TRIM_LIMIT); repository.saveChanges(); if (numAtStatesTrimmed > 0) { LOGGER.debug(() -> String.format("Trimmed %d AT state%s between blocks %d and %d", numAtStatesTrimmed, (numAtStatesTrimmed != 1 ? "s" : ""), - trimStartHeight, upperBatchHeight)); + trimStartHeight, upperTrimHeight)); } else { - trimStartHeight = upperBatchHeight; + // Can we move onto next batch? + if (upperTrimmableHeight > upperBatchHeight) { + repository.getATRepository().setAtTrimHeight(upperBatchHeight); + repository.getATRepository().prepareForAtStateTrimming(); + repository.saveChanges(); + + LOGGER.debug(() -> String.format("Bumping AT state trim height to %d", upperBatchHeight)); + } } } } catch (DataException e) { diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 6897751a..a7d39d3c 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -22,6 +22,7 @@ import java.util.Properties; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -415,8 +416,9 @@ public class Controller extends Thread { final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval(); - Executors.newSingleThreadExecutor(new DaemonThreadFactory("AT states trimmer")).execute(new AtStatesTrimmer()); - Executors.newSingleThreadExecutor(new DaemonThreadFactory("Online sigs trimmer")).execute(new OnlineAccountsSignaturesTrimmer()); + ExecutorService trimExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); + trimExecutor.execute(new AtStatesTrimmer()); + trimExecutor.execute(new OnlineAccountsSignaturesTrimmer()); try { while (!isStopping) { @@ -490,7 +492,17 @@ public class Controller extends Thread { } } } catch (InterruptedException e) { + // Clear interrupted flag so we can shutdown trim threads + Thread.interrupted(); // Fall-through to exit + } finally { + trimExecutor.shutdownNow(); + + try { + trimExecutor.awaitTermination(2L, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // We tried... + } } } diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java index 3d51986b..9b0ffe20 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java @@ -13,17 +13,16 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { private static final Logger LOGGER = LogManager.getLogger(OnlineAccountsSignaturesTrimmer.class); - private static final long INITIAL_SLEEP_PERIOD = 5 * 60 * 1000L; // ms + private static final long INITIAL_SLEEP_PERIOD = 5 * 60 * 1000L + 1234L; // ms - private enum TrimMode { SEARCHING, TRIMMING } private static final long TRIM_INTERVAL = 2 * 1000L; // ms - private static final int TRIM_SEARCH_SIZE = 5000; // blocks - private static final int TRIM_BATCH_SIZE = 500; // blocks - private TrimMode trimMode = TrimMode.SEARCHING; - private int trimStartHeight = 0; + // This has a significant effect on execution time + private static final int TRIM_BATCH_SIZE = 200; // blocks public void run() { + Thread.currentThread().setName("Online Accounts trimmer"); + try (final Repository repository = RepositoryManager.getRepository()) { // Don't even start trimming until initial rush has ended Thread.sleep(INITIAL_SLEEP_PERIOD); @@ -41,39 +40,29 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { long upperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); - if (trimMode == TrimMode.SEARCHING) { - int trimEndHeight = Math.min(trimStartHeight + TRIM_SEARCH_SIZE, upperTrimmableHeight); + int trimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); - LOGGER.debug(() -> String.format("Searching for trimmable online accounts signatures between blocks %d and %d", trimStartHeight, trimEndHeight)); - int foundStartHeight = repository.getBlockRepository().findFirstTrimmableOnlineAccountsSignatureHeight(trimStartHeight, trimEndHeight); + int upperBatchHeight = trimStartHeight + TRIM_BATCH_SIZE; + int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); - if (foundStartHeight == 0) { - // No trimmable online accounts signatures found - trimStartHeight = trimEndHeight; - } else { - trimStartHeight = foundStartHeight; - trimMode = TrimMode.TRIMMING; - LOGGER.debug(() -> String.format("Found first trimmable online accounts signatures at block height %d", trimStartHeight)); - } - - // The above search will probably take enough time by itself so wait until next round - continue; - } - - int upperBatchHeight = Math.min(trimStartHeight + TRIM_BATCH_SIZE, upperTrimmableHeight); - - if (trimStartHeight >= upperBatchHeight) + if (trimStartHeight >= upperTrimHeight) continue; - int numSigsTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(trimStartHeight, upperBatchHeight); + int numSigsTrimmed = repository.getBlockRepository().trimOldOnlineAccountsSignatures(trimStartHeight, upperTrimHeight); repository.saveChanges(); if (numSigsTrimmed > 0) { LOGGER.debug(() -> String.format("Trimmed %d online accounts signature%s between blocks %d and %d", numSigsTrimmed, (numSigsTrimmed != 1 ? "s" : ""), - trimStartHeight, upperBatchHeight)); + trimStartHeight, upperTrimHeight)); } else { - trimStartHeight = upperBatchHeight; + // Can we move onto next batch? + if (upperTrimmableHeight > upperBatchHeight) { + repository.getBlockRepository().setOnlineAccountsSignaturesTrimHeight(upperBatchHeight); + repository.saveChanges(); + + LOGGER.debug(() -> String.format("Bumping online accounts signatures trim height to %d", upperBatchHeight)); + } } } } catch (DataException e) { diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 509569bc..dc8dad15 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -87,8 +87,14 @@ public interface ATRepository { */ public List getBlockATStatesAtHeight(int height) throws DataException; - /** Returns height of first trimmable AT state, or 0 if not found. */ - public int findFirstTrimmableStateHeight(int minHeight, int maxHeight) throws DataException; + /** Returns height of first trimmable AT state. */ + public int getAtTrimHeight() throws DataException; + + /** Sets new base height for AT state trimming. */ + public void setAtTrimHeight(int trimHeight) throws DataException; + + /** Hook to allow repository to prepare/cache info for AT state trimming. */ + public void prepareForAtStateTrimming() throws DataException; /** Trims full AT state data between passed heights. Returns number of trimmed rows. */ public int trimAtStates(int minHeight, int maxHeight, int limit) throws DataException; diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index bb2caaa1..b421a230 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -143,8 +143,11 @@ public interface BlockRepository { */ public List getBlockInfos(Integer startHeight, Integer endHeight, Integer count) throws DataException; - /** Returns height of first trimmable online accounts signatures, or 0 if not found. */ - public int findFirstTrimmableOnlineAccountsSignatureHeight(int minHeight, int maxHeight) throws DataException; + /** Returns height of first trimmable online accounts signatures. */ + public int getOnlineAccountsSignaturesTrimHeight() throws DataException; + + /** Sets new base height for trimming online accounts signatures. */ + public void setOnlineAccountsSignaturesTrimHeight(int trimHeight) throws DataException; /** * Trim online accounts signatures from blocks between passed heights. diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index f5e54f2a..2a1c98bc 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -400,18 +400,61 @@ public class HSQLDBATRepository implements ATRepository { } @Override - public int findFirstTrimmableStateHeight(int minHeight, int maxHeight) throws DataException { - String sql = "SELECT MIN(height) FROM ATStates " - + "WHERE state_data IS NOT NULL " - + "AND height BETWEEN ? AND ?"; + public int getAtTrimHeight() throws DataException { + String sql = "SELECT AT_trim_height FROM DatabaseInfo"; - try (ResultSet resultSet = this.repository.checkedExecute(sql, minHeight, maxHeight)) { + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { if (resultSet == null) return 0; return resultSet.getInt(1); } catch (SQLException e) { - throw new DataException("Unable to find first trimmable AT state in repository", e); + throw new DataException("Unable to fetch AT state trim height from repository", e); + } + } + + @Override + public void setAtTrimHeight(int trimHeight) throws DataException { + String updateSql = "UPDATE DatabaseInfo SET AT_trim_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, trimHeight); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set AT state trim height in repository", e); + } + } + + @Override + public void prepareForAtStateTrimming() throws DataException { + // Rebuild cache of latest, non-finished AT states that we can't trim + String dropSql = "DROP TABLE IF EXISTS LatestATStates"; + + try { + this.repository.executeCheckedUpdate(dropSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to drop temporary latest AT states cache from repository", e); + } + + String createSql = "CREATE TEMPORARY TABLE LatestATStates " + + "AS (" + + "SELECT AT_address, height FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height FROM ATStates " + + "WHERE ATStates.AT_address = ATs.AT_address " + + "ORDER BY AT_address DESC, height DESC LIMIT 1" + + ") " + + "WHERE is_finished IS false" + + ") " + + "WITH DATA " + + "ON COMMIT PRESERVE ROWS"; + + try { + this.repository.executeCheckedUpdate(createSql); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to recreate temporary latest AT states cache in repository", e); } } @@ -425,6 +468,11 @@ public class HSQLDBATRepository implements ATRepository { String sql = "UPDATE ATStates SET state_data = NULL " + "WHERE state_data IS NOT NULL " + "AND height BETWEEN ? AND ? " + + "AND NOT EXISTS(" + + "SELECT TRUE FROM LatestATStates " + + "WHERE LatestATStates.AT_address = ATStates.AT_address " + + "AND LatestATStates.height = ATStates.height" + + ") " + "LIMIT ?"; try { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index 52a6f1d0..8d544e0b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -462,18 +462,28 @@ public class HSQLDBBlockRepository implements BlockRepository { } @Override - public int findFirstTrimmableOnlineAccountsSignatureHeight(int minHeight, int maxHeight) throws DataException { - String sql = "SELECT MIN(height) FROM Blocks " - + "WHERE online_accounts_signatures IS NOT NULL " - + "AND height BETWEEN ? AND ?"; + public int getOnlineAccountsSignaturesTrimHeight() throws DataException { + String sql = "SELECT online_signatures_trim_height FROM DatabaseInfo"; - try (ResultSet resultSet = this.repository.checkedExecute(sql, minHeight, maxHeight)) { + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { if (resultSet == null) return 0; return resultSet.getInt(1); } catch (SQLException e) { - throw new DataException("Unable to find first trimmable online accounts signatures in repository", e); + throw new DataException("Unable to fetch online accounts signatures trim height from repository", e); + } + } + + @Override + public void setOnlineAccountsSignaturesTrimHeight(int trimHeight) throws DataException { + String updateSql = "UPDATE DatabaseInfo SET online_signatures_trim_height = ?"; + + try { + this.repository.executeCheckedUpdate(updateSql, trimHeight); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set online accounts signatures trim height in repository", e); } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 6db40f21..5c8a7e1a 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -665,6 +665,12 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX ATStateHeightIndex on ATStates (height)"); break; + case 26: + // Support for trimming + stmt.execute("ALTER TABLE DatabaseInfo ADD AT_trim_height INT NOT NULL DEFAULT 0"); + stmt.execute("ALTER TABLE DatabaseInfo ADD online_signatures_trim_height INT NOT NULL DEFAULT 0"); + break; + default: // nothing to do return false; From 5cf5c1e1f79bf7151a08358cc003ec003dc3323c Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 1 Oct 2020 13:18:24 +0100 Subject: [PATCH 16/55] Take pressure off GC by not creating/destroying HSQLDB sub-repositories all the time --- .../repository/hsqldb/HSQLDBRepository.java | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 9cf2c07c..760d1ebc 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -53,12 +53,26 @@ public class HSQLDBRepository implements Repository { private static final Logger LOGGER = LogManager.getLogger(HSQLDBRepository.class); protected Connection connection; - protected Deque savepoints = new ArrayDeque<>(3); + protected final Deque savepoints = new ArrayDeque<>(3); protected boolean debugState = false; protected Long slowQueryThreshold = null; protected List sqlStatements; protected long sessionId; - protected Map preparedStatementCache = new HashMap<>(); + protected final Map preparedStatementCache = new HashMap<>(); + + private final ATRepository atRepository = new HSQLDBATRepository(this); + private final AccountRepository accountRepository = new HSQLDBAccountRepository(this); + private final ArbitraryRepository arbitraryRepository = new HSQLDBArbitraryRepository(this); + private final AssetRepository assetRepository = new HSQLDBAssetRepository(this); + private final BlockRepository blockRepository = new HSQLDBBlockRepository(this); + private final ChatRepository chatRepository = new HSQLDBChatRepository(this); + private final CrossChainRepository crossChainRepository = new HSQLDBCrossChainRepository(this); + private final GroupRepository groupRepository = new HSQLDBGroupRepository(this); + private final MessageRepository messageRepository = new HSQLDBMessageRepository(this); + private final NameRepository nameRepository = new HSQLDBNameRepository(this); + private final NetworkRepository networkRepository = new HSQLDBNetworkRepository(this); + private final TransactionRepository transactionRepository = new HSQLDBTransactionRepository(this); + private final VotingRepository votingRepository = new HSQLDBVotingRepository(this); // Constructors @@ -92,67 +106,67 @@ public class HSQLDBRepository implements Repository { @Override public ATRepository getATRepository() { - return new HSQLDBATRepository(this); + return this.atRepository; } @Override public AccountRepository getAccountRepository() { - return new HSQLDBAccountRepository(this); + return this.accountRepository; } @Override public ArbitraryRepository getArbitraryRepository() { - return new HSQLDBArbitraryRepository(this); + return this.arbitraryRepository; } @Override public AssetRepository getAssetRepository() { - return new HSQLDBAssetRepository(this); + return this.assetRepository; } @Override public BlockRepository getBlockRepository() { - return new HSQLDBBlockRepository(this); + return this.blockRepository; } @Override public ChatRepository getChatRepository() { - return new HSQLDBChatRepository(this); + return this.chatRepository; } @Override public CrossChainRepository getCrossChainRepository() { - return new HSQLDBCrossChainRepository(this); + return this.crossChainRepository; } @Override public GroupRepository getGroupRepository() { - return new HSQLDBGroupRepository(this); + return this.groupRepository; } @Override public MessageRepository getMessageRepository() { - return new HSQLDBMessageRepository(this); + return this.messageRepository; } @Override public NameRepository getNameRepository() { - return new HSQLDBNameRepository(this); + return this.nameRepository; } @Override public NetworkRepository getNetworkRepository() { - return new HSQLDBNetworkRepository(this); + return this.networkRepository; } @Override public TransactionRepository getTransactionRepository() { - return new HSQLDBTransactionRepository(this); + return this.transactionRepository; } @Override public VotingRepository getVotingRepository() { - return new HSQLDBVotingRepository(this); + return this.votingRepository; } @Override From 532c697026db2d85dbf85c66b0c4157faf633044 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 2 Oct 2020 12:58:23 +0100 Subject: [PATCH 17/55] Moved AT State & online signatures trimming intervals, batch sizes, limits, etc. to Settings --- .../qortal/controller/AtStatesTrimmer.java | 14 ++------ .../OnlineAccountsSignaturesTrimmer.java | 10 ++---- .../java/org/qortal/settings/Settings.java | 34 +++++++++++++++++++ 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index a0db9650..5b663865 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -13,14 +13,6 @@ public class AtStatesTrimmer implements Runnable { private static final Logger LOGGER = LogManager.getLogger(AtStatesTrimmer.class); - private static final long TRIM_INTERVAL = 2 * 1000L; // ms - - // This has a significant effect on execution time - private static final int TRIM_BATCH_SIZE = 200; // blocks - - // Not so significant effect on execution time - private static final int TRIM_LIMIT = 4000; // rows - @Override public void run() { Thread.currentThread().setName("AT States trimmer"); @@ -32,7 +24,7 @@ public class AtStatesTrimmer implements Runnable { while (!Controller.isStopping()) { repository.discardChanges(); - Thread.sleep(TRIM_INTERVAL); + Thread.sleep(Settings.getInstance().getAtStatesTrimInterval()); BlockData chainTip = Controller.getInstance().getChainTip(); if (chainTip == null || NTP.getTime() == null) @@ -47,13 +39,13 @@ public class AtStatesTrimmer implements Runnable { int trimStartHeight = repository.getATRepository().getAtTrimHeight(); - int upperBatchHeight = trimStartHeight + TRIM_BATCH_SIZE; + int upperBatchHeight = trimStartHeight + Settings.getInstance().getAtStatesTrimBatchSize(); int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); if (trimStartHeight >= upperTrimHeight) continue; - int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperTrimHeight, TRIM_LIMIT); + int numAtStatesTrimmed = repository.getATRepository().trimAtStates(trimStartHeight, upperTrimHeight, Settings.getInstance().getAtStatesTrimLimit()); repository.saveChanges(); if (numAtStatesTrimmed > 0) { diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java index 9b0ffe20..cca8d611 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java @@ -7,6 +7,7 @@ import org.qortal.data.block.BlockData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; import org.qortal.utils.NTP; public class OnlineAccountsSignaturesTrimmer implements Runnable { @@ -15,11 +16,6 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { private static final long INITIAL_SLEEP_PERIOD = 5 * 60 * 1000L + 1234L; // ms - private static final long TRIM_INTERVAL = 2 * 1000L; // ms - - // This has a significant effect on execution time - private static final int TRIM_BATCH_SIZE = 200; // blocks - public void run() { Thread.currentThread().setName("Online Accounts trimmer"); @@ -30,7 +26,7 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { while (!Controller.isStopping()) { repository.discardChanges(); - Thread.sleep(TRIM_INTERVAL); + Thread.sleep(Settings.getInstance().getOnlineSignaturesTrimInterval()); BlockData chainTip = Controller.getInstance().getChainTip(); if (chainTip == null || NTP.getTime() == null) @@ -42,7 +38,7 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { int trimStartHeight = repository.getBlockRepository().getOnlineAccountsSignaturesTrimHeight(); - int upperBatchHeight = trimStartHeight + TRIM_BATCH_SIZE; + int upperBatchHeight = trimStartHeight + Settings.getInstance().getOnlineSignaturesTrimBatchSize(); int upperTrimHeight = Math.min(upperBatchHeight, upperTrimmableHeight); if (trimStartHeight >= upperTrimHeight) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 9a12e880..94ffe839 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -83,8 +83,22 @@ public class Settings { private long repositoryBackupInterval = 0; // ms /** Whether to show a notification when we backup repository. */ private boolean showBackupNotification = false; + /** How long to keep old, full, AT state data (ms). */ private long atStatesMaxLifetime = 2 * 7 * 24 * 60 * 60 * 1000L; // milliseconds + /** How often to attempt AT state trimming (ms). */ + private long atStatesTrimInterval = 5678L; // milliseconds + /** Block height range to scan for trimmable AT states.
+ * This has a significant effect on execution time. */ + private int atStatesTrimBatchSize = 100; // blocks + /** Max number of AT states to trim in one go. */ + private int atStatesTrimLimit = 4000; // records + + /** How often to attempt online accounts signatures trimming (ms). */ + private long onlineSignaturesTrimInterval = 9876L; // milliseconds + /** Block height range to scan for trimmable online accounts signatures.
+ * This has a significant effect on execution time. */ + private int onlineSignaturesTrimBatchSize = 100; // blocks // Peer-to-peer related private boolean isTestNet = false; @@ -420,4 +434,24 @@ public class Settings { return this.atStatesMaxLifetime; } + public long getAtStatesTrimInterval() { + return this.atStatesTrimInterval; + } + + public int getAtStatesTrimBatchSize() { + return this.atStatesTrimBatchSize; + } + + public int getAtStatesTrimLimit() { + return this.atStatesTrimLimit; + } + + public long getOnlineSignaturesTrimInterval() { + return this.onlineSignaturesTrimInterval; + } + + public int getOnlineSignaturesTrimBatchSize() { + return this.onlineSignaturesTrimBatchSize; + } + } From a2038274e1acc09e8d0a1407c51a3699285a057e Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 5 Oct 2020 15:18:41 +0100 Subject: [PATCH 18/55] Keep latest AT state, even if "finished", so we can produce historical trade data --- .../java/org/qortal/repository/hsqldb/HSQLDBATRepository.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 2a1c98bc..14aee83d 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -445,7 +445,6 @@ public class HSQLDBATRepository implements ATRepository { + "WHERE ATStates.AT_address = ATs.AT_address " + "ORDER BY AT_address DESC, height DESC LIMIT 1" + ") " - + "WHERE is_finished IS false" + ") " + "WITH DATA " + "ON COMMIT PRESERVE ROWS"; From 1958444bc4f80f834607b6201323c8a70839df25 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 6 Oct 2020 14:09:42 +0100 Subject: [PATCH 19/55] Add recipient indexes for payment/AT transactions to speed up AT processing --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 5c8a7e1a..1b65939c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -671,6 +671,12 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE DatabaseInfo ADD online_signatures_trim_height INT NOT NULL DEFAULT 0"); break; + case 27: + // More indexes + stmt.execute("CREATE INDEX IF NOT EXISTS PaymentTransactionsRecipientIndex ON PaymentTransactions (recipient)"); + stmt.execute("CREATE INDEX IF NOT EXISTS ATTransactionsRecipientIndex ON ATTransactions (recipient)"); + break; + default: // nothing to do return false; From 6a4388fecc949b6bd1170646464d553ac4d6db42 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 7 Oct 2020 09:45:44 +0100 Subject: [PATCH 20/55] Use cached PreparedStatement for HSQLDB.assertEmptyTransaction + other minor HSQLDB fixes --- .../repository/hsqldb/HSQLDBRepository.java | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 760d1ebc..da69d767 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -208,7 +209,7 @@ public class HSQLDBRepository implements Repository { this.savepoints.clear(); // Before clearing statements so we can log what led to assertion error - assertEmptyTransaction("transaction commit"); + assertEmptyTransaction("transaction rollback"); if (this.sqlStatements != null) this.sqlStatements.clear(); @@ -298,11 +299,12 @@ public class HSQLDBRepository implements Repository { Path oldRepoDirPath = Paths.get(dbPathname).getParent(); // Delete old repository files - Files.walk(oldRepoDirPath) - .sorted(Comparator.reverseOrder()) + try (Stream paths = Files.walk(oldRepoDirPath)) { + paths.sorted(Comparator.reverseOrder()) .map(Path::toFile) .filter(file -> file.getPath().startsWith(dbPathname)) .forEach(File::delete); + } } } catch (NoSuchFileException e) { // Nothing to remove @@ -342,11 +344,12 @@ public class HSQLDBRepository implements Repository { Path backupDirPath = Paths.get(backupPathname).getParent(); String backupDirPathname = backupDirPath.toString(); - Files.walk(backupDirPath) - .sorted(Comparator.reverseOrder()) + try (Stream paths = Files.walk(backupDirPath)) { + paths.sorted(Comparator.reverseOrder()) .map(Path::toFile) .filter(file -> file.getPath().startsWith(backupDirPathname)) .forEach(File::delete); + } } catch (NoSuchFileException e) { // Nothing to remove } catch (SQLException | IOException e) { @@ -411,11 +414,12 @@ public class HSQLDBRepository implements Repository { LOGGER.info("Attempting repository recovery using backup"); // Move old repository files out the way - Files.walk(oldRepoDirPath) - .sorted(Comparator.reverseOrder()) + try (Stream paths = Files.walk(oldRepoDirPath)) { + paths.sorted(Comparator.reverseOrder()) .map(Path::toFile) .filter(file -> file.getPath().startsWith(dbPathname)) .forEach(File::delete); + } try (Statement stmt = connection.createStatement()) { // Now "backup" the backup back to original repository location (the parent). @@ -455,6 +459,10 @@ public class HSQLDBRepository implements Repository { if (this.sqlStatements != null) this.sqlStatements.add(sql); + return cachePreparedStatement(sql); + } + + private PreparedStatement cachePreparedStatement(String sql) throws SQLException { /* * We cache a duplicate PreparedStatement for this SQL string, * which we never close, which means HSQLDB also caches a parsed, @@ -799,7 +807,7 @@ public class HSQLDBRepository implements Repository { /** Logs other HSQLDB sessions then returns passed exception */ public SQLException examineException(SQLException e) { - LOGGER.error(String.format("HSQLDB error (session %d): %s", this.sessionId, e.getMessage()), e); + LOGGER.error(() -> String.format("HSQLDB error (session %d): %s", this.sessionId, e.getMessage()), e); logStatements(); @@ -833,14 +841,19 @@ public class HSQLDBRepository implements Repository { } private void assertEmptyTransaction(String context) throws DataException { - try (Statement stmt = this.connection.createStatement()) { + String sql = "SELECT transaction, transaction_size FROM information_schema.system_sessions WHERE session_id = ?"; + + try { + PreparedStatement stmt = this.cachePreparedStatement(sql); + stmt.setLong(1, this.sessionId); + // Diagnostic check for uncommitted changes - if (!stmt.execute("SELECT transaction, transaction_size FROM information_schema.system_sessions WHERE session_id = " + this.sessionId)) // TRANSACTION_SIZE() broken? + if (!stmt.execute()) // TRANSACTION_SIZE() broken? throw new DataException("Unable to check repository status after " + context); try (ResultSet resultSet = stmt.getResultSet()) { if (resultSet == null || !resultSet.next()) { - LOGGER.warn(String.format("Unable to check repository status after %s", context)); + LOGGER.warn(() -> String.format("Unable to check repository status after %s", context)); return; } @@ -848,7 +861,11 @@ public class HSQLDBRepository implements Repository { int transactionCount = resultSet.getInt(2); if (inTransaction && transactionCount != 0) { - LOGGER.warn(String.format("Uncommitted changes (%d) after %s, session [%d]", transactionCount, context, this.sessionId), new Exception("Uncommitted repository changes")); + LOGGER.warn(() -> String.format("Uncommitted changes (%d) after %s, session [%d]", + transactionCount, + context, + this.sessionId), + new Exception("Uncommitted repository changes")); logStatements(); } } From 9ceff90f422b18628cb624658f08a8da21deb1f2 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 7 Oct 2020 10:31:18 +0100 Subject: [PATCH 21/55] Upgrade to CIYAM-AT v1.3.8 with slight performance improvements --- lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar | Bin 0 -> 151844 bytes lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom | 9 +++++++++ lib/org/ciyam/AT/maven-metadata-local.xml | 5 +++-- pom.xml | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar create mode 100644 lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom diff --git a/lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar b/lib/org/ciyam/AT/1.3.8/AT-1.3.8.jar new file mode 100644 index 0000000000000000000000000000000000000000..5e7c36772fa378e86985ca3aee082432f13478d2 GIT binary patch literal 151844 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 bWF?A@IyTVaMnfwB{_y}a1D1f;XlVZrpv}fA literal 0 HcmV?d00001 diff --git a/lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom b/lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom new file mode 100644 index 00000000..106adc38 --- /dev/null +++ b/lib/org/ciyam/AT/1.3.8/AT-1.3.8.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.ciyam + AT + 1.3.8 + POM was created from install:install-file + diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml index ff99d403..8f8b1f6e 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -3,13 +3,14 @@ org.ciyam AT - 1.3.7 + 1.3.8 1.3.4 1.3.5 1.3.6 1.3.7 + 1.3.8 - 20200812131412 + 20200925114415 diff --git a/pom.xml b/pom.xml index ddb04109..97750fb2 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ 0.15.5 1.64 ${maven.build.timestamp} - 1.3.7 + 1.3.8 3.6 1.8 1.2.2 From 98564aa8bf3427074fdfd4063fe3a7fed8891b93 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 7 Oct 2020 14:56:05 +0100 Subject: [PATCH 22/55] Fix SQL logic error when fetching trade offers --- .../java/org/qortal/repository/hsqldb/HSQLDBATRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 14aee83d..13d376a8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -147,7 +147,7 @@ public class HSQLDBATRepository implements ATRepository { bindParams.add(codeHash); if (isExecutable != null) { - sql.append("AND is_finished = ? "); + sql.append("AND is_finished != ? "); bindParams.add(isExecutable); } From 6a24f787c44fa0604696f5b154d9deb2a0b6301e Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 7 Oct 2020 14:56:58 +0100 Subject: [PATCH 23/55] Bump to v1.3.6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 97750fb2..1061ddc9 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 1.3.5 + 1.3.6 jar 0.15.5 From 38a64bdd9e2fb72689f28cce4e908a2abf00daf7 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 12 Oct 2020 14:35:10 +0100 Subject: [PATCH 24/55] Prevent HSQLDB prepared statement cache invalidation when rebuilding latest AT states cache --- .../repository/hsqldb/HSQLDBATRepository.java | 29 ++++++++----------- .../hsqldb/HSQLDBDatabaseUpdates.java | 8 +++++ 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 13d376a8..d2463c80 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -428,32 +428,27 @@ public class HSQLDBATRepository implements ATRepository { @Override public void prepareForAtStateTrimming() throws DataException { // Rebuild cache of latest, non-finished AT states that we can't trim - String dropSql = "DROP TABLE IF EXISTS LatestATStates"; - + String deleteSql = "DELETE FROM LatestATStates"; try { - this.repository.executeCheckedUpdate(dropSql); + this.repository.executeCheckedUpdate(deleteSql); } catch (SQLException e) { repository.examineException(e); - throw new DataException("Unable to drop temporary latest AT states cache from repository", e); + throw new DataException("Unable to delete temporary latest AT states cache from repository", e); } - String createSql = "CREATE TEMPORARY TABLE LatestATStates " - + "AS (" - + "SELECT AT_address, height FROM ATs " - + "CROSS JOIN LATERAL(" - + "SELECT height FROM ATStates " - + "WHERE ATStates.AT_address = ATs.AT_address " - + "ORDER BY AT_address DESC, height DESC LIMIT 1" - + ") " + String insertSql = "INSERT INTO LatestATStates (" + + "SELECT AT_address, height FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height FROM ATStates " + + "WHERE ATStates.AT_address = ATs.AT_address " + + "ORDER BY AT_address DESC, height DESC LIMIT 1" + ") " - + "WITH DATA " - + "ON COMMIT PRESERVE ROWS"; - + + ")"; try { - this.repository.executeCheckedUpdate(createSql); + this.repository.executeCheckedUpdate(insertSql); } catch (SQLException e) { repository.examineException(e); - throw new DataException("Unable to recreate temporary latest AT states cache in repository", e); + throw new DataException("Unable to populate temporary latest AT states cache in repository", e); } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 1b65939c..60a611f8 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -677,6 +677,14 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE INDEX IF NOT EXISTS ATTransactionsRecipientIndex ON ATTransactions (recipient)"); break; + case 28: + // Latest AT state cache + stmt.execute("CREATE TEMPORARY TABLE IF NOT EXISTS LatestATStates (" + + "AT_address QortalAddress NOT NULL, " + + "height INT NOT NULL" + + ")"); + break; + default: // nothing to do return false; From 0389007491c03edbe0dc4929eb12d00e0448ab31 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 28 Oct 2020 08:38:11 +0000 Subject: [PATCH 25/55] Skip trimming while performing synchronization --- src/main/java/org/qortal/controller/AtStatesTrimmer.java | 4 ++++ .../qortal/controller/OnlineAccountsSignaturesTrimmer.java | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/main/java/org/qortal/controller/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/AtStatesTrimmer.java index 5b663865..f141abd7 100644 --- a/src/main/java/org/qortal/controller/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/AtStatesTrimmer.java @@ -30,6 +30,10 @@ public class AtStatesTrimmer implements Runnable { if (chainTip == null || NTP.getTime() == null) continue; + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) + continue; + long currentTrimmableTimestamp = NTP.getTime() - Settings.getInstance().getAtStatesMaxLifetime(); // We want to keep AT states near the tip of our copy of blockchain so we can process/orphan nearby blocks long chainTrimmableTimestamp = chainTip.getTimestamp() - Settings.getInstance().getAtStatesMaxLifetime(); diff --git a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java index cca8d611..e9b846fc 100644 --- a/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java +++ b/src/main/java/org/qortal/controller/OnlineAccountsSignaturesTrimmer.java @@ -32,6 +32,10 @@ public class OnlineAccountsSignaturesTrimmer implements Runnable { if (chainTip == null || NTP.getTime() == null) continue; + // Don't even attempt if we're mid-sync as our repository requests will be delayed for ages + if (Controller.getInstance().isSynchronizing()) + continue; + // Trim blockchain by removing 'old' online accounts signatures long upperTrimmableTimestamp = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMaxLifetime(); int upperTrimmableHeight = repository.getBlockRepository().getHeightFromTimestamp(upperTrimmableTimestamp); From cec25ce279a3b7bbad29d4ea3aed66e7a7a62b18 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 28 Oct 2020 08:41:23 +0000 Subject: [PATCH 26/55] Add API call POST /peers/commonblock as debugging aid --- .../qortal/api/resource/PeersResource.java | 72 +++++++++++++++++++ .../org/qortal/controller/Synchronizer.java | 7 +- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/PeersResource.java b/src/main/java/org/qortal/api/resource/PeersResource.java index 80cb5fa5..a66fef4a 100644 --- a/src/main/java/org/qortal/api/resource/PeersResource.java +++ b/src/main/java/org/qortal/api/resource/PeersResource.java @@ -8,6 +8,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -26,10 +28,17 @@ import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.ConnectedPeer; +import org.qortal.controller.Controller; +import org.qortal.controller.Synchronizer; +import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.data.block.BlockSummaryData; import org.qortal.data.network.PeerData; import org.qortal.network.Network; +import org.qortal.network.Peer; 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; import org.qortal.utils.NTP; @@ -260,4 +269,67 @@ public class PeersResource { } } + @POST + @Path("/commonblock") + @Operation( + summary = "Report common block with given peer.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", example = "node2.qortal.org" + ) + ) + ), + responses = { + @ApiResponse( + description = "the block", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = BlockSummaryData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) + public List commonBlock(String targetPeerAddress) { + Security.checkApiCallAllowed(request); + + try { + // Try to resolve passed address to make things easier + PeerAddress peerAddress = PeerAddress.fromString(targetPeerAddress); + InetSocketAddress resolvedAddress = peerAddress.toSocketAddress(); + + List peers = Network.getInstance().getHandshakedPeers(); + Peer targetPeer = peers.stream().filter(peer -> peer.getResolvedAddress().equals(resolvedAddress)).findFirst().orElse(null); + + if (targetPeer == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + try (final Repository repository = RepositoryManager.getRepository()) { + int ourInitialHeight = Controller.getInstance().getChainHeight(); + boolean force = true; + List peerBlockSummaries = new ArrayList<>(); + + SynchronizationResult findCommonBlockResult = Synchronizer.getInstance().fetchSummariesFromCommonBlock(repository, targetPeer, ourInitialHeight, force, peerBlockSummaries); + if (findCommonBlockResult != SynchronizationResult.OK) + return null; + + return peerBlockSummaries; + } + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + } catch (UnknownHostException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (InterruptedException e) { + return null; + } + } + } diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 5af2030d..7aede4f2 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -175,7 +175,7 @@ public class Synchronizer { * @throws DataException * @throws InterruptedException */ - private SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List blockSummariesFromCommon) throws DataException, InterruptedException { + public SynchronizationResult fetchSummariesFromCommonBlock(Repository repository, Peer peer, int ourHeight, boolean force, List 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; @@ -320,11 +320,12 @@ public class Synchronizer { BigInteger ourChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, ourBlockSummaries); BigInteger peerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, peerBlockSummaries); + NumberFormat formatter = new DecimalFormat("0.###E0"); + LOGGER.debug(String.format("Our chain weight: %s, peer's chain weight: %s (higher is better)", formatter.format(ourChainWeight), formatter.format(peerChainWeight))); + // If our blockchain has greater weight then don't synchronize with peer if (ourChainWeight.compareTo(peerChainWeight) >= 0) { LOGGER.debug(String.format("Not synchronizing with peer %s as we have better blockchain", peer)); - NumberFormat formatter = new DecimalFormat("0.###E0"); - LOGGER.debug(String.format("Our chain weight: %s, peer's chain weight: %s (higher is better)", formatter.format(ourChainWeight), formatter.format(peerChainWeight))); return SynchronizationResult.INFERIOR_CHAIN; } } From da78c73485fa23d670b283c0280270c5ff80b594 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 28 Oct 2020 08:42:59 +0000 Subject: [PATCH 27/55] Remove extraneous call to Controller.onNewBlock() after synchronization, as this call is performed per-block inside Synchronizer --- src/main/java/org/qortal/controller/Controller.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index a7d39d3c..4f4ae6b1 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -656,9 +656,6 @@ public class Controller extends Thread { // Reset our cache of inferior chains inferiorChainSignatures.clear(); - // Update chain-tip, systray, notify peers, websockets, etc. - this.onNewBlock(newChainTip); - Network network = Network.getInstance(); network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newChainTip)); } From f3b82580670c92cc4075c1133499b092f3c00670 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 28 Oct 2020 08:46:30 +0000 Subject: [PATCH 28/55] Add more latest block caching to reduce repository accesses, especially for requests from remote peers --- .../java/org/qortal/block/BlockChain.java | 2 +- .../org/qortal/controller/Controller.java | 194 ++++++++++++++---- .../org/qortal/controller/Synchronizer.java | 2 +- .../qortal/network/message/BlockMessage.java | 1 + 4 files changed, 156 insertions(+), 43 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 95ecc41b..520f8952 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -568,7 +568,7 @@ public class BlockChain { orphanBlockData = repository.getBlockRepository().fromHeight(height); repository.discardChanges(); // clear transaction status to prevent deadlocks - Controller.getInstance().onNewBlock(orphanBlockData); + Controller.getInstance().onOrphanedBlock(orphanBlockData); } return true; diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 4f4ae6b1..6e52ae2c 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -16,6 +16,7 @@ import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Properties; @@ -24,6 +25,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -135,7 +137,10 @@ public class Controller extends Thread { private ExecutorService callbackExecutor = Executors.newFixedThreadPool(3); private volatile boolean notifyGroupMembershipChange = false; - private volatile BlockData chainTip = null; + private static final int LATEST_BLOCKS_SIZE = 10; // To cover typical Synchronizer request + a few spare + /** Latest blocks on our chain. Note: tail/last is the latest block. */ + private final Deque latestBlocks = new LinkedList<>(); + private volatile BlockMessage latestBlockMessage = null; private long repositoryBackupTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms @@ -238,21 +243,36 @@ public class Controller extends Thread { /** Returns current blockchain height, or 0 if it's not available. */ public int getChainHeight() { - BlockData blockData = this.chainTip; - if (blockData == null) - return 0; + synchronized (this.latestBlocks) { + BlockData blockData = this.latestBlocks.peekLast(); + if (blockData == null) + return 0; - return blockData.getHeight(); + return blockData.getHeight(); + } } /** Returns highest block, or null if it's not available. */ public BlockData getChainTip() { - return this.chainTip; + synchronized (this.latestBlocks) { + return this.latestBlocks.peekLast(); + } } - /** Cache new blockchain tip. */ - public void setChainTip(BlockData blockData) { - this.chainTip = blockData; + public void refillLatestBlocksCache() throws DataException { + // Set initial chain height/tip + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().getLastBlock(); + + synchronized (this.latestBlocks) { + this.latestBlocks.clear(); + + for (int i = 0; i < LATEST_BLOCKS_SIZE && blockData != null; ++i) { + this.latestBlocks.addFirst(blockData); + blockData = repository.getBlockRepository().fromHeight(blockData.getHeight() - 1); + } + } + } } public ReentrantLock getBlockchainLock() { @@ -334,13 +354,8 @@ public class Controller extends Thread { try { BlockChain.validate(); - // Set initial chain height/tip - try (final Repository repository = RepositoryManager.getRepository()) { - BlockData blockData = repository.getBlockRepository().getLastBlock(); - - Controller.getInstance().setChainTip(blockData); - LOGGER.info(String.format("Our chain height at start-up: %d", blockData.getHeight())); - } + Controller.getInstance().refillLatestBlocksCache(); + LOGGER.info(String.format("Our chain height at start-up: %d", Controller.getInstance().getChainHeight())); } catch (DataException e) { LOGGER.error("Couldn't validate blockchain", e); Gui.getInstance().fatalError("Blockchain validation issue", e); @@ -572,9 +587,10 @@ public class Controller extends Thread { public SynchronizationResult actuallySynchronize(Peer peer, boolean force) throws InterruptedException { boolean hasStatusChanged = false; + BlockData priorChainTip = this.getChainTip(); synchronized (this.syncLock) { - this.syncPercent = (this.chainTip.getHeight() * 100) / peer.getChainTipData().getLastHeight(); + this.syncPercent = (priorChainTip.getHeight() * 100) / peer.getChainTipData().getLastHeight(); // Only update SysTray if we're potentially changing height if (this.syncPercent < 100) { @@ -586,8 +602,6 @@ public class Controller extends Thread { if (hasStatusChanged) updateSysTray(); - BlockData priorChainTip = this.chainTip; - try { SynchronizationResult syncResult = Synchronizer.getInstance().synchronize(peer, force); switch (syncResult) { @@ -850,11 +864,84 @@ public class Controller extends Thread { // Protective copy BlockData blockDataCopy = new BlockData(latestBlockData); - this.setChainTip(blockDataCopy); + synchronized (this.latestBlocks) { + BlockData cachedChainTip = this.latestBlocks.peekLast(); + + if (cachedChainTip != null && Arrays.equals(cachedChainTip.getSignature(), blockDataCopy.getReference())) { + // Chain tip is parent for new latest block, so we can safely add new latest block + this.latestBlocks.addLast(latestBlockData); + } else { + if (cachedChainTip != null) + // Chain tip didn't match - potentially abnormal behaviour? + LOGGER.debug(() -> String.format("Cached chain tip %.8s not parent for new latest block %.8s (reference %.8s)", + Base58.encode(cachedChainTip.getSignature()), + Base58.encode(blockDataCopy.getSignature()), + Base58.encode(blockDataCopy.getReference()))); + + // Protectively rebuild cache + try { + this.refillLatestBlocksCache(); + } catch (DataException e) { + LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e); + } + } + } + + this.onNewOrOrphanedBlock(blockDataCopy, NewBlockEvent::new); + } + + public static class OrphanedBlockEvent implements Event { + private final BlockData blockData; + + public OrphanedBlockEvent(BlockData blockData) { + this.blockData = blockData; + } + + public BlockData getBlockData() { + return this.blockData; + } + } + + /** + * Callback for when we've orphaned a block. + *

+ * See WARNING for {@link EventBus#notify(Event)} + * to prevent deadlocks. + */ + public void onOrphanedBlock(BlockData latestBlockData) { + // Protective copy + BlockData blockDataCopy = new BlockData(latestBlockData); + + synchronized (this.latestBlocks) { + BlockData cachedChainTip = this.latestBlocks.pollLast(); + + if (cachedChainTip != null && Arrays.equals(cachedChainTip.getReference(), blockDataCopy.getSignature())) { + // Chain tip was parent for new latest block that has been orphaned, so we're good + } else { + if (cachedChainTip != null) + // Chain tip didn't match - potentially abnormal behaviour? + LOGGER.debug(() -> String.format("Cached chain tip %.8s (reference %.8s) was not parent for new latest block %.8s", + Base58.encode(cachedChainTip.getSignature()), + Base58.encode(cachedChainTip.getReference()), + Base58.encode(blockDataCopy.getSignature()))); + + // Protectively rebuild cache + try { + this.refillLatestBlocksCache(); + } catch (DataException e) { + LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e); + } + } + } + + this.onNewOrOrphanedBlock(blockDataCopy, OrphanedBlockEvent::new); + } + + private void onNewOrOrphanedBlock(BlockData blockDataCopy, Function eventConstructor) { requestSysTrayUpdate = true; // Notify listeners, trade-bot, etc. - EventBus.INSTANCE.notify(new NewBlockEvent(blockDataCopy)); + EventBus.INSTANCE.notify(eventConstructor.apply(blockDataCopy)); if (this.notifyGroupMembershipChange) { this.notifyGroupMembershipChange = false; @@ -955,8 +1042,21 @@ public class Controller extends Thread { GetBlockMessage getBlockMessage = (GetBlockMessage) message; byte[] signature = getBlockMessage.getSignature(); + BlockMessage blockMessage = this.latestBlockMessage; + + // Check cached latest block message + if (blockMessage != null && Arrays.equals(blockMessage.getBlockData().getSignature(), signature)) { + blockMessage.setId(message.getId()); + + if (!peer.sendMessage(blockMessage)) + peer.disconnect("failed to send block"); + + return; + } + try (final Repository repository = RepositoryManager.getRepository()) { BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData == null) { // We don't have this block @@ -973,10 +1073,16 @@ public class Controller extends Thread { Block block = new Block(repository, blockData); - Message blockMessage = new BlockMessage(block); + blockMessage = new BlockMessage(block); blockMessage.setId(message.getId()); + + // This call also causes the other needed data to be pulled in from repository if (!peer.sendMessage(blockMessage)) peer.disconnect("failed to send block"); + + // If request is for latest block, cache it + if (Arrays.equals(this.getChainTip().getSignature(), signature)) + this.latestBlockMessage = blockMessage; } catch (DataException e) { LOGGER.error(String.format("Repository issue while send block %s to peer %s", Base58.encode(signature), peer), e); } @@ -1023,32 +1129,38 @@ public class Controller extends Thread { private void onNetworkGetBlockSummariesMessage(Peer peer, Message message) { GetBlockSummariesMessage getBlockSummariesMessage = (GetBlockSummariesMessage) message; - byte[] parentSignature = getBlockSummariesMessage.getParentSignature(); + final byte[] parentSignature = getBlockSummariesMessage.getParentSignature(); - try (final Repository repository = RepositoryManager.getRepository()) { - List blockSummaries = new ArrayList<>(); + List blockSummaries = new ArrayList<>(); - int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested()); + // Attempt to serve from our cache of latest blocks + synchronized (this.latestBlocks) { + blockSummaries = this.latestBlocks.stream() + .dropWhile(cachedBlockData -> Arrays.equals(cachedBlockData.getSignature(), parentSignature)) + .map(BlockSummaryData::new) + .collect(Collectors.toList()); + } + + if (blockSummaries.isEmpty()) + try (final Repository repository = RepositoryManager.getRepository()) { + int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested()); - do { BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); - if (blockData == null) - // No more blocks to send to peer - break; + while (blockData != null && blockSummaries.size() < numberRequested) { + BlockSummaryData blockSummary = new BlockSummaryData(blockData); + blockSummaries.add(blockSummary); - BlockSummaryData blockSummary = new BlockSummaryData(blockData); - blockSummaries.add(blockSummary); - parentSignature = blockData.getSignature(); - } while (blockSummaries.size() < numberRequested); + blockData = repository.getBlockRepository().fromReference(blockData.getSignature()); + } + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e); + } - Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); - blockSummariesMessage.setId(message.getId()); - if (!peer.sendMessage(blockSummariesMessage)) - peer.disconnect("failed to send block summaries"); - } catch (DataException e) { - LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e); - } + Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); + blockSummariesMessage.setId(message.getId()); + if (!peer.sendMessage(blockSummariesMessage)) + peer.disconnect("failed to send block summaries"); } private void onNetworkGetSignaturesV2Message(Peer peer, Message message) { diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 7aede4f2..747711b2 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -412,7 +412,7 @@ public class Synchronizer { orphanBlockData = repository.getBlockRepository().fromHeight(ourHeight); repository.discardChanges(); // clear transaction status to prevent deadlocks - Controller.getInstance().onNewBlock(orphanBlockData); + Controller.getInstance().onOrphanedBlock(orphanBlockData); } LOGGER.debug(String.format("Orphaned blocks back to height %d, sig %.8s - applying new blocks from peer %s", commonBlockHeight, commonBlockSig58, peer)); diff --git a/src/main/java/org/qortal/network/message/BlockMessage.java b/src/main/java/org/qortal/network/message/BlockMessage.java index 8ca86ee6..e63dce92 100644 --- a/src/main/java/org/qortal/network/message/BlockMessage.java +++ b/src/main/java/org/qortal/network/message/BlockMessage.java @@ -34,6 +34,7 @@ public class BlockMessage extends Message { super(MessageType.BLOCK); this.block = block; + this.blockData = block.getBlockData(); this.height = block.getBlockData().getHeight(); } From 9c18a33d7f3b876d5bdb3c9a368d2765f79dd680 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 28 Oct 2020 09:08:16 +0000 Subject: [PATCH 29/55] Improve tools/build-auto-update.sh when working on detached HEAD --- tools/build-auto-update.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/build-auto-update.sh b/tools/build-auto-update.sh index db651a39..4124f1e2 100755 --- a/tools/build-auto-update.sh +++ b/tools/build-auto-update.sh @@ -13,7 +13,7 @@ fi cd ${git_dir} # Check we are in 'master' branch -branch_name=$( git symbolic-ref -q HEAD ) +branch_name=$( git symbolic-ref -q HEAD || echo ) branch_name=${branch_name##refs/heads/} echo "Current git branch: ${branch_name}" if [ "${branch_name}" != "master" ]; then @@ -78,5 +78,7 @@ git add ${project}.update git commit --message "XORed, auto-update JAR based on commit ${short_hash}" git push --set-upstream origin --force-with-lease ${update_branch} +branch_name=${branch_name-master} + echo "Changing back to '${branch_name}' branch" git checkout --force ${branch_name} From d2a92db921490a42ddf8b80fec28cbbf827041ce Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 29 Oct 2020 11:02:02 +0000 Subject: [PATCH 30/55] More caching for GetBlockMessage. Added API call GET /admin/enginestats to monitor cache usage --- .../org/qortal/api/model/ActivitySummary.java | 55 +++++- .../qortal/api/resource/AdminResource.java | 36 +++- .../org/qortal/controller/Controller.java | 157 ++++++++++++++---- .../qortal/network/message/BlockMessage.java | 6 + 4 files changed, 212 insertions(+), 42 deletions(-) diff --git a/src/main/java/org/qortal/api/model/ActivitySummary.java b/src/main/java/org/qortal/api/model/ActivitySummary.java index 27b5ed8d..e8e9a3aa 100644 --- a/src/main/java/org/qortal/api/model/ActivitySummary.java +++ b/src/main/java/org/qortal/api/model/ActivitySummary.java @@ -1,5 +1,6 @@ package org.qortal.api.model; +import java.util.Collections; import java.util.EnumMap; import java.util.Map; @@ -13,17 +14,61 @@ import org.qortal.transaction.Transaction.TransactionType; @XmlAccessorType(XmlAccessType.FIELD) public class ActivitySummary { - public int blockCount; - public int transactionCount; - public int assetsIssued; - public int namesRegistered; + private int blockCount; + private int assetsIssued; + private int namesRegistered; // Assuming TransactionType values are contiguous so 'length' equals count @XmlJavaTypeAdapter(TransactionCountMapXmlAdapter.class) - public Map transactionCountByType = new EnumMap<>(TransactionType.class); + private Map transactionCountByType = new EnumMap<>(TransactionType.class); + private int totalTransactionCount = 0; public ActivitySummary() { // Needed for JAXB } + public int getBlockCount() { + return this.blockCount; + } + + public void setBlockCount(int blockCount) { + this.blockCount = blockCount; + } + + public int getTotalTransactionCount() { + return this.totalTransactionCount; + } + + public int getAssetsIssued() { + return this.assetsIssued; + } + + public void setAssetsIssued(int assetsIssued) { + this.assetsIssued = assetsIssued; + } + + public int getNamesRegistered() { + return this.namesRegistered; + } + + public void setNamesRegistered(int namesRegistered) { + this.namesRegistered = namesRegistered; + } + + public Map getTransactionCountByType() { + return Collections.unmodifiableMap(this.transactionCountByType); + } + + public void setTransactionCountByType(TransactionType transactionType, int transactionCount) { + this.transactionCountByType.put(transactionType, transactionCount); + + this.totalTransactionCount = this.transactionCountByType.values().stream().mapToInt(Integer::intValue).sum(); + } + + public void setTransactionCountByType(Map transactionCountByType) { + this.transactionCountByType = new EnumMap<>(transactionCountByType); + + this.totalTransactionCount = this.transactionCountByType.values().stream().mapToInt(Integer::intValue).sum(); + } + } diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 52d7a9e7..6fbadb96 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -182,6 +182,8 @@ public class AdminResource { ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) public ActivitySummary summary() { + Security.checkApiCallAllowed(request); + ActivitySummary summary = new ActivitySummary(); LocalDate date = LocalDate.now(); @@ -193,16 +195,13 @@ public class AdminResource { int startHeight = repository.getBlockRepository().getHeightFromTimestamp(start); int endHeight = repository.getBlockRepository().getBlockchainHeight(); - summary.blockCount = endHeight - startHeight; + summary.setBlockCount(endHeight - startHeight); - summary.transactionCountByType = repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight); + summary.setTransactionCountByType(repository.getTransactionRepository().getTransactionSummary(startHeight + 1, endHeight)); - for (Integer count : summary.transactionCountByType.values()) - summary.transactionCount += count; + summary.setAssetsIssued(repository.getAssetRepository().getRecentAssetIds(start).size()); - summary.assetsIssued = repository.getAssetRepository().getRecentAssetIds(start).size(); - - summary.namesRegistered = repository.getNameRepository().getRecentNames(start).size(); + summary.setNamesRegistered (repository.getNameRepository().getRecentNames(start).size()); return summary; } catch (DataException e) { @@ -210,6 +209,29 @@ public class AdminResource { } } + @GET + @Path("/enginestats") + @Operation( + summary = "Fetch statistics snapshot for core engine", + responses = { + @ApiResponse( + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + array = @ArraySchema( + schema = @Schema( + implementation = Controller.StatsSnapshot.class + ) + ) + ) + ) + } + ) + public Controller.StatsSnapshot getEngineStats() { + Security.checkApiCallAllowed(request); + + return Controller.getInstance().getStatsSnapshot(); + } + @GET @Path("/mintingaccounts") @Operation( diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 6e52ae2c..f1116364 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -16,6 +16,7 @@ import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -24,11 +25,15 @@ import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bouncycastle.jce.provider.BouncyCastleProvider; @@ -137,10 +142,18 @@ public class Controller extends Thread { private ExecutorService callbackExecutor = Executors.newFixedThreadPool(3); private volatile boolean notifyGroupMembershipChange = false; - private static final int LATEST_BLOCKS_SIZE = 10; // To cover typical Synchronizer request + a few spare + private static final int BLOCK_CACHE_SIZE = 10; // To cover typical Synchronizer request + a few spare /** Latest blocks on our chain. Note: tail/last is the latest block. */ private final Deque latestBlocks = new LinkedList<>(); - private volatile BlockMessage latestBlockMessage = null; + + /** Cache of BlockMessages, indexed by block signature */ + @SuppressWarnings("serial") + private final LinkedHashMap blockMessageCache = new LinkedHashMap<>() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return this.size() > BLOCK_CACHE_SIZE; + } + }; private long repositoryBackupTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms @@ -188,6 +201,47 @@ public class Controller extends Thread { /** Cache of latest blocks' online accounts */ Deque> latestBlocksOnlineAccounts = new ArrayDeque<>(MAX_BLOCKS_CACHED_ONLINE_ACCOUNTS); + // Stats + @XmlAccessorType(XmlAccessType.FIELD) + public static class StatsSnapshot { + public static class GetBlockMessageStats { + public AtomicLong requests = new AtomicLong(); + public AtomicLong cacheHits = new AtomicLong(); + public AtomicLong unknownBlocks = new AtomicLong(); + public AtomicLong cacheFills = new AtomicLong(); + + public GetBlockMessageStats() { + } + } + public GetBlockMessageStats getBlockMessageStats = new GetBlockMessageStats(); + + public static class GetBlockSummariesStats { + public AtomicLong requests = new AtomicLong(); + public AtomicLong cacheHits = new AtomicLong(); + public AtomicLong fullyFromCache = new AtomicLong(); + + public GetBlockSummariesStats() { + } + } + public GetBlockSummariesStats getBlockSummariesStats = new GetBlockSummariesStats(); + + public static class GetBlockSignaturesV2Stats { + public AtomicLong requests = new AtomicLong(); + public AtomicLong cacheHits = new AtomicLong(); + public AtomicLong fullyFromCache = new AtomicLong(); + + public GetBlockSignaturesV2Stats() { + } + } + public GetBlockSignaturesV2Stats getBlockSignaturesV2Stats = new GetBlockSignaturesV2Stats(); + + public AtomicLong latestBlocksCacheRefills = new AtomicLong(); + + public StatsSnapshot() { + } + } + private final StatsSnapshot stats = new StatsSnapshot(); + // Constructors private Controller(String[] args) { @@ -267,7 +321,7 @@ public class Controller extends Thread { synchronized (this.latestBlocks) { this.latestBlocks.clear(); - for (int i = 0; i < LATEST_BLOCKS_SIZE && blockData != null; ++i) { + for (int i = 0; i < BLOCK_CACHE_SIZE && blockData != null; ++i) { this.latestBlocks.addFirst(blockData); blockData = repository.getBlockRepository().fromHeight(blockData.getHeight() - 1); } @@ -870,6 +924,10 @@ public class Controller extends Thread { if (cachedChainTip != null && Arrays.equals(cachedChainTip.getSignature(), blockDataCopy.getReference())) { // Chain tip is parent for new latest block, so we can safely add new latest block this.latestBlocks.addLast(latestBlockData); + + // Trim if necessary + if (latestBlockData.getHeight() - this.latestBlocks.peekFirst().getHeight() >= BLOCK_CACHE_SIZE) + this.latestBlocks.pollFirst(); } else { if (cachedChainTip != null) // Chain tip didn't match - potentially abnormal behaviour? @@ -878,8 +936,10 @@ public class Controller extends Thread { Base58.encode(blockDataCopy.getSignature()), Base58.encode(blockDataCopy.getReference()))); - // Protectively rebuild cache + // Defensively rebuild cache try { + this.stats.latestBlocksCacheRefills.incrementAndGet(); + this.refillLatestBlocksCache(); } catch (DataException e) { LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e); @@ -925,8 +985,10 @@ public class Controller extends Thread { Base58.encode(cachedChainTip.getReference()), Base58.encode(blockDataCopy.getSignature()))); - // Protectively rebuild cache + // Defensively rebuild cache try { + this.stats.latestBlocksCacheRefills.incrementAndGet(); + this.refillLatestBlocksCache(); } catch (DataException e) { LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e); @@ -1041,14 +1103,20 @@ public class Controller extends Thread { private void onNetworkGetBlockMessage(Peer peer, Message message) { GetBlockMessage getBlockMessage = (GetBlockMessage) message; byte[] signature = getBlockMessage.getSignature(); + this.stats.getBlockMessageStats.requests.incrementAndGet(); - BlockMessage blockMessage = this.latestBlockMessage; + ByteArray signatureAsByteArray = new ByteArray(signature); + + BlockMessage cachedBlockMessage = this.blockMessageCache.get(signatureAsByteArray); // Check cached latest block message - if (blockMessage != null && Arrays.equals(blockMessage.getBlockData().getSignature(), signature)) { - blockMessage.setId(message.getId()); + if (cachedBlockMessage != null) { + this.stats.getBlockMessageStats.cacheHits.incrementAndGet(); - if (!peer.sendMessage(blockMessage)) + // We need to duplicate it to prevent multiple threads setting ID on the same message + BlockMessage clonedBlockMessage = cachedBlockMessage.cloneWithNewId(message.getId()); + + if (!peer.sendMessage(clonedBlockMessage)) peer.disconnect("failed to send block"); return; @@ -1059,6 +1127,7 @@ public class Controller extends Thread { if (blockData == null) { // We don't have this block + this.stats.getBlockMessageStats.unknownBlocks.getAndIncrement(); // Send valid, yet unexpected message type in response, so peer's synchronizer doesn't have to wait for timeout LOGGER.debug(() -> String.format("Sending 'block unknown' response to peer %s for GET_BLOCK request for unknown block %s", peer, Base58.encode(signature))); @@ -1073,16 +1142,19 @@ public class Controller extends Thread { Block block = new Block(repository, blockData); - blockMessage = new BlockMessage(block); + BlockMessage blockMessage = new BlockMessage(block); blockMessage.setId(message.getId()); // This call also causes the other needed data to be pulled in from repository if (!peer.sendMessage(blockMessage)) peer.disconnect("failed to send block"); - // If request is for latest block, cache it - if (Arrays.equals(this.getChainTip().getSignature(), signature)) - this.latestBlockMessage = blockMessage; + // If request is for a recent block, cache it + if (getChainHeight() - blockData.getHeight() <= BLOCK_CACHE_SIZE) { + this.stats.getBlockMessageStats.cacheFills.incrementAndGet(); + + this.blockMessageCache.put(new ByteArray(blockData.getSignature()), blockMessage); + } } catch (DataException e) { LOGGER.error(String.format("Repository issue while send block %s to peer %s", Base58.encode(signature), peer), e); } @@ -1130,6 +1202,7 @@ public class Controller extends Thread { private void onNetworkGetBlockSummariesMessage(Peer peer, Message message) { GetBlockSummariesMessage getBlockSummariesMessage = (GetBlockSummariesMessage) message; final byte[] parentSignature = getBlockSummariesMessage.getParentSignature(); + this.stats.getBlockSummariesStats.requests.incrementAndGet(); List blockSummaries = new ArrayList<>(); @@ -1141,7 +1214,7 @@ public class Controller extends Thread { .collect(Collectors.toList()); } - if (blockSummaries.isEmpty()) + if (blockSummaries.isEmpty()) { try (final Repository repository = RepositoryManager.getRepository()) { int numberRequested = Math.min(Network.MAX_BLOCK_SUMMARIES_PER_REPLY, getBlockSummariesMessage.getNumberRequested()); @@ -1156,6 +1229,12 @@ public class Controller extends Thread { } catch (DataException e) { LOGGER.error(String.format("Repository issue while sending block summaries after %s to peer %s", Base58.encode(parentSignature), peer), e); } + } else { + this.stats.getBlockSummariesStats.cacheHits.incrementAndGet(); + + if (blockSummaries.size() >= getBlockSummariesMessage.getNumberRequested()) + this.stats.getBlockSummariesStats.fullyFromCache.incrementAndGet(); + } Message blockSummariesMessage = new BlockSummariesMessage(blockSummaries); blockSummariesMessage.setId(message.getId()); @@ -1165,29 +1244,43 @@ public class Controller extends Thread { private void onNetworkGetSignaturesV2Message(Peer peer, Message message) { GetSignaturesV2Message getSignaturesMessage = (GetSignaturesV2Message) message; - byte[] parentSignature = getSignaturesMessage.getParentSignature(); + final byte[] parentSignature = getSignaturesMessage.getParentSignature(); + this.stats.getBlockSignaturesV2Stats.requests.incrementAndGet(); - try (final Repository repository = RepositoryManager.getRepository()) { - List signatures = new ArrayList<>(); + List signatures = new ArrayList<>(); - do { + // Attempt to serve from our cache of latest blocks + synchronized (this.latestBlocks) { + signatures = this.latestBlocks.stream() + .dropWhile(cachedBlockData -> Arrays.equals(cachedBlockData.getSignature(), parentSignature)) + .map(BlockData::getSignature) + .collect(Collectors.toList()); + } + + if (signatures.isEmpty()) { + try (final Repository repository = RepositoryManager.getRepository()) { + int numberRequested = getSignaturesMessage.getNumberRequested(); BlockData blockData = repository.getBlockRepository().fromReference(parentSignature); - if (blockData == null) - // No more signatures to send to peer - break; + while (blockData != null && signatures.size() < numberRequested) { + signatures.add(blockData.getSignature()); - parentSignature = blockData.getSignature(); - signatures.add(parentSignature); - } while (signatures.size() < getSignaturesMessage.getNumberRequested()); + blockData = repository.getBlockRepository().fromReference(blockData.getSignature()); + } + } catch (DataException e) { + LOGGER.error(String.format("Repository issue while sending V2 signatures after %s to peer %s", Base58.encode(parentSignature), peer), e); + } + } else { + this.stats.getBlockSignaturesV2Stats.cacheHits.incrementAndGet(); - Message signaturesMessage = new SignaturesMessage(signatures); - signaturesMessage.setId(message.getId()); - if (!peer.sendMessage(signaturesMessage)) - peer.disconnect("failed to send signatures (v2)"); - } catch (DataException e) { - LOGGER.error(String.format("Repository issue while sending V2 signatures after %s to peer %s", Base58.encode(parentSignature), peer), e); + if (signatures.size() >= getSignaturesMessage.getNumberRequested()) + this.stats.getBlockSignaturesV2Stats.fullyFromCache.incrementAndGet(); } + + Message signaturesMessage = new SignaturesMessage(signatures); + signaturesMessage.setId(message.getId()); + if (!peer.sendMessage(signaturesMessage)) + peer.disconnect("failed to send signatures (v2)"); } private void onNetworkHeightV2Message(Peer peer, Message message) { @@ -1798,4 +1891,8 @@ public class Controller extends Thread { return now - offset; } + public StatsSnapshot getStatsSnapshot() { + return this.stats; + } + } diff --git a/src/main/java/org/qortal/network/message/BlockMessage.java b/src/main/java/org/qortal/network/message/BlockMessage.java index e63dce92..b07dc8b1 100644 --- a/src/main/java/org/qortal/network/message/BlockMessage.java +++ b/src/main/java/org/qortal/network/message/BlockMessage.java @@ -94,4 +94,10 @@ public class BlockMessage extends Message { } } + public BlockMessage cloneWithNewId(int newId) { + BlockMessage clone = new BlockMessage(this.block); + clone.setId(newId); + return clone; + } + } From 88da8d949f430932e67ff26500745dfcee8e2026 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 2 Nov 2020 10:45:21 +0000 Subject: [PATCH 31/55] Don't allow latest blocks cache to be empty --- src/main/java/org/qortal/controller/Controller.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index f1116364..4611e7f7 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -926,7 +926,7 @@ public class Controller extends Thread { this.latestBlocks.addLast(latestBlockData); // Trim if necessary - if (latestBlockData.getHeight() - this.latestBlocks.peekFirst().getHeight() >= BLOCK_CACHE_SIZE) + if (this.latestBlocks.size() >= BLOCK_CACHE_SIZE) this.latestBlocks.pollFirst(); } else { if (cachedChainTip != null) @@ -974,9 +974,13 @@ public class Controller extends Thread { synchronized (this.latestBlocks) { BlockData cachedChainTip = this.latestBlocks.pollLast(); + boolean refillNeeded = false; if (cachedChainTip != null && Arrays.equals(cachedChainTip.getReference(), blockDataCopy.getSignature())) { // Chain tip was parent for new latest block that has been orphaned, so we're good + + // However, if we've emptied the cache then we will need to refill it + refillNeeded = this.latestBlocks.isEmpty(); } else { if (cachedChainTip != null) // Chain tip didn't match - potentially abnormal behaviour? @@ -986,6 +990,10 @@ public class Controller extends Thread { Base58.encode(blockDataCopy.getSignature()))); // Defensively rebuild cache + refillNeeded = true; + } + + if (refillNeeded) try { this.stats.latestBlocksCacheRefills.incrementAndGet(); @@ -993,7 +1001,6 @@ public class Controller extends Thread { } catch (DataException e) { LOGGER.warn(() -> "Couldn't refill latest blocks cache?", e); } - } } this.onNewOrOrphanedBlock(blockDataCopy, OrphanedBlockEvent::new); From ee0841026012c9bf74ce69482a80be1a20757401 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 2 Nov 2020 10:46:51 +0000 Subject: [PATCH 32/55] More trace-level debugging in Synchronizer to help diagnose chain reorg issues --- src/main/java/org/qortal/controller/Synchronizer.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 747711b2..47182119 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -406,6 +406,8 @@ public class Synchronizer { Block block = new Block(repository, orphanBlockData); block.orphan(); + LOGGER.trace(String.format("Orphaned block height %d, sig %.8s", ourHeight, Base58.encode(orphanBlockData.getSignature()))); + repository.saveChanges(); --ourHeight; @@ -433,6 +435,8 @@ public class Synchronizer { newBlock.process(); + LOGGER.trace(String.format("Processed block height %d, sig %.8s", newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getBlockData().getSignature()))); + repository.saveChanges(); Controller.getInstance().onNewBlock(newBlock.getBlockData()); @@ -515,6 +519,8 @@ public class Synchronizer { newBlock.process(); + LOGGER.trace(String.format("Processed block height %d, sig %.8s", newBlock.getBlockData().getHeight(), Base58.encode(newBlock.getBlockData().getSignature()))); + repository.saveChanges(); Controller.getInstance().onNewBlock(newBlock.getBlockData()); From de2fc78ad125287a0428f9826440b9f09db9e56c Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 2 Nov 2020 10:47:27 +0000 Subject: [PATCH 33/55] Add support for SHA256 digest of ByteBuffer in Crypto class --- src/main/java/org/qortal/crypto/Crypto.java | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/java/org/qortal/crypto/Crypto.java b/src/main/java/org/qortal/crypto/Crypto.java index a21ac594..49cdd2fb 100644 --- a/src/main/java/org/qortal/crypto/Crypto.java +++ b/src/main/java/org/qortal/crypto/Crypto.java @@ -1,5 +1,6 @@ package org.qortal.crypto; +import java.nio.ByteBuffer; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; @@ -42,6 +43,27 @@ public abstract class Crypto { } } + /** + * Returns 32-byte SHA-256 digest of message passed in input. + * + * @param input + * variable-length byte[] message + * @return byte[32] digest, or null if SHA-256 algorithm can't be accessed + */ + public static byte[] digest(ByteBuffer input) { + if (input == null) + return null; + + try { + // SHA2-256 + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(input); + return sha256.digest(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 message digest not available"); + } + } + /** * Returns 32-byte digest of two rounds of SHA-256 on message passed in input. * From 8f06765cafab0f8c7d02a6a613fb887b5d815df1 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 2 Nov 2020 11:15:53 +0000 Subject: [PATCH 34/55] Networking performance improvements and message sending bugfix --- src/main/java/org/qortal/network/Peer.java | 46 ++++++++++++++-- .../org/qortal/network/message/Message.java | 54 +++++++++---------- 2 files changed, 67 insertions(+), 33 deletions(-) diff --git a/src/main/java/org/qortal/network/Peer.java b/src/main/java/org/qortal/network/Peer.java index 9c8ce8a8..84f29ac9 100644 --- a/src/main/java/org/qortal/network/Peer.java +++ b/src/main/java/org/qortal/network/Peer.java @@ -33,6 +33,7 @@ import org.qortal.settings.Settings; import org.qortal.utils.ExecuteProduceConsume; import org.qortal.utils.NTP; +import com.google.common.hash.HashCode; import com.google.common.net.HostAndPort; import com.google.common.net.InetAddresses; @@ -348,21 +349,37 @@ public class Peer { if (this.byteBuffer == null) this.byteBuffer = ByteBuffer.allocate(Network.getInstance().getMaxMessageSize()); + final int priorPosition = this.byteBuffer.position(); final int bytesRead = this.socketChannel.read(this.byteBuffer); if (bytesRead == -1) { this.disconnect("EOF"); return; } - LOGGER.trace(() -> String.format("Received %d bytes from peer %s", bytesRead, this)); + LOGGER.trace(() -> { + if (bytesRead > 0) { + byte[] leadingBytes = new byte[Math.min(bytesRead, 8)]; + this.byteBuffer.asReadOnlyBuffer().position(priorPosition).get(leadingBytes); + String leadingHex = HashCode.fromBytes(leadingBytes).toString(); + + return String.format("Received %d bytes, starting %s, into byteBuffer[%d] from peer %s", + bytesRead, + leadingHex, + priorPosition, + this); + } else { + return String.format("Received %d bytes into byteBuffer[%d] from peer %s", bytesRead, priorPosition, this); + } + }); final boolean wasByteBufferFull = !this.byteBuffer.hasRemaining(); while (true) { final Message message; // Can we build a message from buffer now? + ByteBuffer readOnlyBuffer = this.byteBuffer.asReadOnlyBuffer().flip(); try { - message = Message.fromByteBuffer(this.byteBuffer); + message = Message.fromByteBuffer(readOnlyBuffer); } catch (MessageException e) { LOGGER.debug(String.format("%s, from peer %s", e.getMessage(), this)); this.disconnect(e.getMessage()); @@ -387,6 +404,13 @@ public class Peer { LOGGER.trace(() -> String.format("Received %s message with ID %d from peer %s", message.getType().name(), message.getId(), this)); + // Tidy up buffers: + this.byteBuffer.flip(); + // Read-only, flipped buffer's position will be after end of message, so copy that + this.byteBuffer.position(readOnlyBuffer.position()); + // Copy bytes after read message to front of buffer, adjusting position accordingly, reset limit to capacity + this.byteBuffer.compact(); + BlockingQueue queue = this.replyQueues.get(message.getId()); if (queue != null) { // Adding message to queue will unblock thread waiting for response @@ -399,7 +423,7 @@ public class Peer { // Add message to pending queue if (!this.pendingMessages.offer(message)) { - LOGGER.info(String.format("No room to queue message from peer %s - discarding", this)); + LOGGER.info(() -> String.format("No room to queue message from peer %s - discarding", this)); return; } @@ -454,10 +478,24 @@ public class Peer { while (outputBuffer.hasRemaining()) { int bytesWritten = this.socketChannel.write(outputBuffer); + LOGGER.trace(() -> String.format("Sent %d bytes of %s message with ID %d to peer %s", + bytesWritten, + message.getType().name(), + message.getId(), + this)); + if (bytesWritten == 0) // Underlying socket's internal buffer probably full, // so wait a short while for bytes to actually be transmitted over the wire - this.socketChannel.wait(1L); + + /* + * NOSONAR squid:S2276 - we don't want to use this.socketChannel.wait() + * as this releases the lock held by synchronized() above + * and would allow another thread to send another message, + * potentially interleaving them on-the-wire, causing checksum failures + * and connection loss. + */ + Thread.sleep(1L); //NOSONAR squid:S2276 } } } catch (MessageException e) { diff --git a/src/main/java/org/qortal/network/message/Message.java b/src/main/java/org/qortal/network/message/Message.java index 9dfdc6bc..cc90fe81 100644 --- a/src/main/java/org/qortal/network/message/Message.java +++ b/src/main/java/org/qortal/network/message/Message.java @@ -160,80 +160,72 @@ public abstract class Message { /** * Attempt to read a message from byte buffer. * - * @param byteBuffer + * @param readOnlyBuffer * @return null if no complete message can be read * @throws MessageException */ - public static Message fromByteBuffer(ByteBuffer byteBuffer) throws MessageException { + public static Message fromByteBuffer(ByteBuffer readOnlyBuffer) throws MessageException { try { - byteBuffer.flip(); - - ByteBuffer readBuffer = byteBuffer.asReadOnlyBuffer(); - // Read only enough bytes to cover Message "magic" preamble byte[] messageMagic = new byte[MAGIC_LENGTH]; - readBuffer.get(messageMagic); + readOnlyBuffer.get(messageMagic); if (!Arrays.equals(messageMagic, Network.getInstance().getMessageMagic())) // Didn't receive correct Message "magic" throw new MessageException("Received incorrect message 'magic'"); // Find supporting object - int typeValue = readBuffer.getInt(); + int typeValue = readOnlyBuffer.getInt(); MessageType messageType = MessageType.valueOf(typeValue); if (messageType == null) // Unrecognised message type throw new MessageException(String.format("Received unknown message type [%d]", typeValue)); // Optional message ID - byte hasId = readBuffer.get(); + byte hasId = readOnlyBuffer.get(); int id = -1; if (hasId != 0) { - id = readBuffer.getInt(); + id = readOnlyBuffer.getInt(); if (id <= 0) // Invalid ID throw new MessageException("Invalid negative ID"); } - int dataSize = readBuffer.getInt(); + int dataSize = readOnlyBuffer.getInt(); if (dataSize > MAX_DATA_SIZE) // Too large throw new MessageException(String.format("Declared data length %d larger than max allowed %d", dataSize, MAX_DATA_SIZE)); + // Don't have all the data yet? + if (dataSize > 0 && dataSize + CHECKSUM_LENGTH > readOnlyBuffer.remaining()) + return null; + ByteBuffer dataSlice = null; if (dataSize > 0) { byte[] expectedChecksum = new byte[CHECKSUM_LENGTH]; - readBuffer.get(expectedChecksum); + readOnlyBuffer.get(expectedChecksum); - // Remember this position in readBuffer so we can pass to Message subclass - dataSlice = readBuffer.slice(); - - // Consume data from buffer - byte[] data = new byte[dataSize]; - readBuffer.get(data); - - // We successfully read all the data bytes, so we can set limit on dataSlice + // Slice data in readBuffer so we can pass to Message subclass + dataSlice = readOnlyBuffer.slice(); dataSlice.limit(dataSize); // Test checksum - byte[] actualChecksum = generateChecksum(data); + byte[] actualChecksum = generateChecksum(dataSlice); if (!Arrays.equals(expectedChecksum, actualChecksum)) throw new MessageException("Message checksum incorrect"); + + // Reset position after being consumed by generateChecksum + dataSlice.position(0); + // Update position in readOnlyBuffer + readOnlyBuffer.position(readOnlyBuffer.position() + dataSize); } - Message message = messageType.fromByteBuffer(id, dataSlice); - - // We successfully read a message, so bump byteBuffer's position to reflect this - byteBuffer.position(readBuffer.position()); - - return message; + return messageType.fromByteBuffer(id, dataSlice); } catch (BufferUnderflowException e) { // Not enough bytes to fully decode message... return null; - } finally { - byteBuffer.compact(); } } @@ -241,6 +233,10 @@ public abstract class Message { return Arrays.copyOfRange(Crypto.digest(data), 0, CHECKSUM_LENGTH); } + protected static byte[] generateChecksum(ByteBuffer dataBuffer) { + return Arrays.copyOfRange(Crypto.digest(dataBuffer), 0, CHECKSUM_LENGTH); + } + public byte[] toBytes() throws MessageException { try { ByteArrayOutputStream bytes = new ByteArrayOutputStream(256); From 6c40727027004a8f4d75ccd75d6a58a5b4f8a784 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 2 Nov 2020 11:16:40 +0000 Subject: [PATCH 35/55] More reporting for slow HSQLDB queries/commits --- .../repository/hsqldb/HSQLDBRepository.java | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index da69d767..d2623441 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -184,8 +184,20 @@ public class HSQLDBRepository implements Repository { @Override public void saveChanges() throws DataException { + long beforeQuery = this.slowQueryThreshold == null ? 0 : System.currentTimeMillis(); + try { this.connection.commit(); + + if (this.slowQueryThreshold != null) { + long queryTime = System.currentTimeMillis() - beforeQuery; + + if (queryTime > this.slowQueryThreshold) { + LOGGER.info(() -> String.format("[Session %d] HSQLDB COMMIT took %d ms", this.sessionId, queryTime), new SQLException("slow commit")); + + logStatements(); + } + } } catch (SQLException e) { throw new DataException("commit error", e); } finally { @@ -512,7 +524,7 @@ public class HSQLDBRepository implements Repository { long queryTime = System.currentTimeMillis() - beforeQuery; if (queryTime > this.slowQueryThreshold) { - LOGGER.info(() -> String.format("HSQLDB query took %d ms: %s", queryTime, sql), new SQLException("slow query")); + LOGGER.info(() -> String.format("[Session %d] HSQLDB query took %d ms: %s", this.sessionId, queryTime, sql), new SQLException("slow query")); logStatements(); } @@ -605,7 +617,7 @@ public class HSQLDBRepository implements Repository { long queryTime = System.currentTimeMillis() - beforeQuery; if (queryTime > this.slowQueryThreshold) { - LOGGER.info(() -> String.format("HSQLDB query took %d ms: %s", queryTime, sql), new SQLException("slow query")); + LOGGER.info(() -> String.format("[Session %d] HSQLDB query took %d ms: %s", this.sessionId, queryTime, sql), new SQLException("slow query")); logStatements(); } @@ -799,15 +811,15 @@ public class HSQLDBRepository implements Repository { if (this.sqlStatements == null) return; - LOGGER.info(() -> String.format("HSQLDB SQL statements (session %d) leading up to this were:", this.sessionId)); + LOGGER.info(() -> String.format("[Session %d] HSQLDB SQL statements leading up to this were:", this.sessionId)); for (String sql : this.sqlStatements) - LOGGER.info(sql); + LOGGER.info(() -> String.format("[Session %d] %s", this.sessionId, sql)); } /** Logs other HSQLDB sessions then returns passed exception */ public SQLException examineException(SQLException e) { - LOGGER.error(() -> String.format("HSQLDB error (session %d): %s", this.sessionId, e.getMessage()), e); + LOGGER.error(() -> String.format("[Session %d] HSQLDB error: %s", this.sessionId, e.getMessage()), e); logStatements(); From 7b056a832f335fcd4afc906d331f402c38d45d04 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 2 Nov 2020 11:49:21 +0000 Subject: [PATCH 36/55] Turn off HSQLDB redo-log "blockchain.log" and periodically call "CHECKPOINT" instead. Checkpointing interval is 1 hour by default, changable in settings via "repositoryCheckpointInterval" plus corresponding "showCheckpointNotifications" SysTray flags (off by default). Added entries to SysTray_en i18n properties, and converted SysTray_ru to ISO-8559-1. --- .../org/qortal/controller/Controller.java | 14 ++++ .../org/qortal/repository/Repository.java | 2 + .../qortal/repository/RepositoryManager.java | 8 +++ .../hsqldb/HSQLDBDatabaseUpdates.java | 6 ++ .../repository/hsqldb/HSQLDBRepository.java | 9 +++ .../java/org/qortal/settings/Settings.java | 12 ++++ src/main/resources/i18n/SysTray_en.properties | 4 ++ src/main/resources/i18n/SysTray_ru.properties | 70 ++++++++----------- 8 files changed, 84 insertions(+), 41 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 4611e7f7..fc72475d 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -156,6 +156,7 @@ public class Controller extends Thread { }; private long repositoryBackupTimestamp = startTime; // ms + private long repositoryCheckpointTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms private long deleteExpiredTimestamp = startTime + DELETE_EXPIRED_INTERVAL; // ms @@ -484,6 +485,7 @@ public class Controller extends Thread { Thread.currentThread().setName("Controller"); final long repositoryBackupInterval = Settings.getInstance().getRepositoryBackupInterval(); + final long repositoryCheckpointInterval = Settings.getInstance().getRepositoryCheckpointInterval(); ExecutorService trimExecutor = Executors.newCachedThreadPool(new DaemonThreadFactory()); trimExecutor.execute(new AtStatesTrimmer()); @@ -529,6 +531,18 @@ public class Controller extends Thread { final long requestMinimumTimestamp = now - ARBITRARY_REQUEST_TIMEOUT; arbitraryDataRequests.entrySet().removeIf(entry -> entry.getValue().getC() < requestMinimumTimestamp); + // Time to 'checkpoint' uncommitted repository writes? + if (now >= repositoryCheckpointTimestamp + repositoryCheckpointInterval) { + repositoryCheckpointTimestamp = now + repositoryCheckpointInterval; + + if (Settings.getInstance().getShowCheckpointNotification()) + SysTray.getInstance().showMessage(Translator.INSTANCE.translate("SysTray", "DB_CHECKPOINT"), + Translator.INSTANCE.translate("SysTray", "PERFORMING_DB_CHECKPOINT"), + MessageType.INFO); + + RepositoryManager.checkpoint(true); + } + // Give repository a chance to backup (if enabled) if (repositoryBackupInterval > 0 && now >= repositoryBackupTimestamp + repositoryBackupInterval) { repositoryBackupTimestamp = now + repositoryBackupInterval; diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index cc3a5336..cbfaab97 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -47,6 +47,8 @@ public interface Repository extends AutoCloseable { public void backup(boolean quick) throws DataException; + public void checkpoint(boolean quick) throws DataException; + public void performPeriodicMaintenance() throws DataException; } diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index e375be96..e3427954 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -35,6 +35,14 @@ public abstract class RepositoryManager { } } + public static void checkpoint(boolean quick) { + try (final Repository repository = getRepository()) { + repository.checkpoint(quick); + } catch (DataException e) { + // Checkpoint is best-effort so don't complain + } + } + public static void rebuild() throws DataException { RepositoryFactory oldRepositoryFactory = repositoryFactory; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 60a611f8..3255c045 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -685,6 +685,12 @@ public class HSQLDBDatabaseUpdates { + ")"); break; + case 29: + // Turn off HSQLDB redo-log "blockchain.log" and periodically call "CHECKPOINT" ourselves + stmt.execute("SET FILES LOG FALSE"); + stmt.execute("CHECKPOINT"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index d2623441..8391d7ae 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -376,6 +376,15 @@ public class HSQLDBRepository implements Repository { } } + @Override + public void checkpoint(boolean quick) throws DataException { + try (Statement stmt = this.connection.createStatement()) { + stmt.execute(quick ? "CHECKPOINT" : "CHECKPOINT DEFRAG"); + } catch (SQLException e) { + throw new DataException("Unable to perform repositor checkpoint"); + } + } + @Override public void performPeriodicMaintenance() throws DataException { // Defrag DB - takes a while! diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 94ffe839..1a989c2e 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -83,6 +83,10 @@ public class Settings { private long repositoryBackupInterval = 0; // ms /** Whether to show a notification when we backup repository. */ private boolean showBackupNotification = false; + /** How long between repository checkpoints (ms). */ + private long repositoryCheckpointInterval = 60 * 60 * 1000L; // 1 hour (ms) default + /** Whether to show a notification when we perform repository 'checkpoint'. */ + private boolean showCheckpointNotification = false; /** How long to keep old, full, AT state data (ms). */ private long atStatesMaxLifetime = 2 * 7 * 24 * 60 * 60 * 1000L; // milliseconds @@ -430,6 +434,14 @@ public class Settings { return this.showBackupNotification; } + public long getRepositoryCheckpointInterval() { + return this.repositoryCheckpointInterval; + } + + public boolean getShowCheckpointNotification() { + return this.showCheckpointNotification; + } + public long getAtStatesMaxLifetime() { return this.atStatesMaxLifetime; } diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index f41c1a32..e581335d 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -19,6 +19,8 @@ CREATING_BACKUP_OF_DB_FILES = Creating backup of database files... DB_BACKUP = Database Backup +DB_CHECKPOINT = Database Checkpoint + EXIT = Exit MINTING_DISABLED = NOT minting @@ -34,6 +36,8 @@ NTP_NAG_TEXT_WINDOWS = Select "Synchronize clock" from menu to fix. OPEN_UI = Open UI +PERFORMING_DB_CHECKPOINT = Saving uncommitted database changes... + SYNCHRONIZE_CLOCK = Synchronize clock SYNCHRONIZING_BLOCKCHAIN = Synchronizing diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index f7012034..9b93213e 100644 --- a/src/main/resources/i18n/SysTray_ru.properties +++ b/src/main/resources/i18n/SysTray_ru.properties @@ -1,41 +1,29 @@ -#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) -# SysTray pop-up menu - -APPLYING_UPDATE_AND_RESTARTING = Применение автоматического обновления и перезапуска... - -AUTO_UPDATE = Автоматическое обновление - -BLOCK_HEIGHT = Высота блока - -CHECK_TIME_ACCURACY = Проверка точного времени - -CONNECTING = Подключение - -CONNECTION = Соединение - -CONNECTIONS = Соединений - -CREATING_BACKUP_OF_DB_FILES = Создание резервной копии файлов базы данных... - -DB_BACKUP = Резервное копирование базы данных - -EXIT = Выход - -MINTING_DISABLED = Чеканка отключена - -MINTING_ENABLED = Чеканка активна - -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = Часы компьютера неточны! - -NTP_NAG_TEXT_UNIX = Установите службу NTP, чтобы получить точное время - -NTP_NAG_TEXT_WINDOWS = Выберите "Синхронизация времени" из меню, чтобы исправить - -OPEN_UI = Открыть пользовательский интерфейс - -SYNCHRONIZE_CLOCK = Синхронизировать время - -SYNCHRONIZING_BLOCKCHAIN = Синхронизация цепи - -SYNCHRONIZING_CLOCK = Проверка времени +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu + +APPLYING_UPDATE_AND_RESTARTING = \u00D0\u009F\u00D1\u0080\u00D0\u00B8\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 \u00D0\u00B0\u00D0\u00B2\u00D1\u0082\u00D0\u00BE\u00D0\u00BC\u00D0\u00B0\u00D1\u0082\u00D0\u00B8\u00D1\u0087\u00D0\u00B5\u00D1\u0081\u00D0\u00BA\u00D0\u00BE\u00D0\u00B3\u00D0\u00BE \u00D0\u00BE\u00D0\u00B1\u00D0\u00BD\u00D0\u00BE\u00D0\u00B2\u00D0\u00BB\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D1\u008F \u00D0\u00B8 \u00D0\u00BF\u00D0\u00B5\u00D1\u0080\u00D0\u00B5\u00D0\u00B7\u00D0\u00B0\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D0\u00BA\u00D0\u00B0... + +AUTO_UPDATE = \u00D0\u0090\u00D0\u00B2\u00D1\u0082\u00D0\u00BE\u00D0\u00BC\u00D0\u00B0\u00D1\u0082\u00D0\u00B8\u00D1\u0087\u00D0\u00B5\u00D1\u0081\u00D0\u00BA\u00D0\u00BE\u00D0\u00B5 \u00D0\u00BE\u00D0\u00B1\u00D0\u00BD\u00D0\u00BE\u00D0\u00B2\u00D0\u00BB\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 + +BLOCK_HEIGHT = \u00D0\u0092\u00D1\u008B\u00D1\u0081\u00D0\u00BE\u00D1\u0082\u00D0\u00B0 \u00D0\u00B1\u00D0\u00BB\u00D0\u00BE\u00D0\u00BA\u00D0\u00B0 + +CHECK_TIME_ACCURACY = \u00D0\u009F\u00D1\u0080\u00D0\u00BE\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BA\u00D0\u00B0 \u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00BE\u00D0\u00B3\u00D0\u00BE \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8 + +CONNECTING = \u00D0\u009F\u00D0\u00BE\u00D0\u00B4\u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 + +CONNECTION = \u00D0\u00A1\u00D0\u00BE\u00D0\u00B5\u00D0\u00B4\u00D0\u00B8\u00D0\u00BD\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 + +CONNECTIONS = \u00D0\u00A1\u00D0\u00BE\u00D0\u00B5\u00D0\u00B4\u00D0\u00B8\u00D0\u00BD\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B9 + +MINTING_DISABLED = \u00D0\u00A7\u00D0\u00B5\u00D0\u00BA\u00D0\u00B0\u00D0\u00BD\u00D0\u00BA\u00D0\u00B0 \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087\u00D0\u00B5\u00D0\u00BD\u00D0\u00B0 + +MINTING_ENABLED = \u00D0\u00A7\u00D0\u00B5\u00D0\u00BA\u00D0\u00B0\u00D0\u00BD\u00D0\u00BA\u00D0\u00B0 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00BD\u00D0\u00B0 + +# Nagging about lack of NTP time sync +NTP_NAG_CAPTION = \u00D0\u00A7\u00D0\u00B0\u00D1\u0081\u00D1\u008B \u00D0\u00BA\u00D0\u00BE\u00D0\u00BC\u00D0\u00BF\u00D1\u008C\u00D1\u008E\u00D1\u0082\u00D0\u00B5\u00D1\u0080\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D1\u008B! + +NTP_NAG_TEXT_UNIX = \u00D0\u00A3\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D0\u00BD\u00D0\u00BE\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5 \u00D1\u0081\u00D0\u00BB\u00D1\u0083\u00D0\u00B6\u00D0\u00B1\u00D1\u0083 NTP, \u00D1\u0087\u00D1\u0082\u00D0\u00BE\u00D0\u00B1\u00D1\u008B \u00D0\u00BF\u00D0\u00BE\u00D0\u00BB\u00D1\u0083\u00D1\u0087\u00D0\u00B8\u00D1\u0082\u00D1\u008C \u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00BE\u00D0\u00B5 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D1\u008F + +OPEN_UI = \u00D0\u009E\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008C \u00D0\u00BF\u00D0\u00BE\u00D0\u00BB\u00D1\u008C\u00D0\u00B7\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D1\u0081\u00D0\u00BA\u00D0\u00B8\u00D0\u00B9 \u00D0\u00B8\u00D0\u00BD\u00D1\u0082\u00D0\u00B5\u00D1\u0080\u00D1\u0084\u00D0\u00B5\u00D0\u00B9\u00D1\u0081 + +SYNCHRONIZING_CLOCK = \u00D0\u009F\u00D1\u0080\u00D0\u00BE\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BA\u00D0\u00B0 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8 From c125a5365532c23f3cea92a5dc0339ae30b9aa92 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 2 Nov 2020 11:52:06 +0000 Subject: [PATCH 37/55] More (optionally) logging when comparing chains with peers. Support for potential future minor consensus change. --- src/main/java/org/qortal/block/Block.java | 38 ++++++++++++++++++- .../org/qortal/controller/Synchronizer.java | 8 ++-- .../org/qortal/test/ChainWeightTests.java | 6 ++- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index e1273072..b977a613 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -6,6 +6,8 @@ import static java.util.stream.Collectors.toMap; import java.math.BigDecimal; import java.math.BigInteger; import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.text.NumberFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -15,6 +17,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.account.Account; @@ -791,15 +794,46 @@ public class Block { return BigInteger.valueOf(blockSummaryData.getOnlineAccountsCount()).shiftLeft(ACCOUNTS_COUNT_SHIFT).add(keyDistance); } - public static BigInteger calcChainWeight(int commonBlockHeight, byte[] commonBlockSignature, List blockSummaries) { + public static BigInteger calcChainWeight(int commonBlockHeight, byte[] commonBlockSignature, List blockSummaries, int maxHeight) { BigInteger cumulativeWeight = BigInteger.ZERO; int parentHeight = commonBlockHeight; byte[] parentBlockSignature = commonBlockSignature; + NumberFormat formatter = new DecimalFormat("0.###E0"); + boolean isLogging = LOGGER.getLevel().isLessSpecificThan(Level.TRACE); for (BlockSummaryData blockSummaryData : blockSummaries) { - cumulativeWeight = cumulativeWeight.shiftLeft(CHAIN_WEIGHT_SHIFT).add(calcBlockWeight(parentHeight, parentBlockSignature, blockSummaryData)); + StringBuilder stringBuilder = isLogging ? new StringBuilder(512) : null; + + if (isLogging) + stringBuilder.append(formatter.format(cumulativeWeight)).append(" -> "); + + cumulativeWeight = cumulativeWeight.shiftLeft(CHAIN_WEIGHT_SHIFT); + if (isLogging) + stringBuilder.append(formatter.format(cumulativeWeight)).append(" + "); + + BigInteger blockWeight = calcBlockWeight(parentHeight, parentBlockSignature, blockSummaryData); + if (isLogging) + stringBuilder.append("(height: ") + .append(parentHeight + 1) + .append(", online: ") + .append(blockSummaryData.getOnlineAccountsCount()) + .append(") ") + .append(formatter.format(blockWeight)); + + cumulativeWeight = cumulativeWeight.add(blockWeight); + if (isLogging) + stringBuilder.append(" -> ").append(formatter.format(cumulativeWeight)); + + if (isLogging && blockSummaries.size() > 1) + LOGGER.debug(() -> stringBuilder.toString()); //NOSONAR S1612 (false positive?) + parentHeight = blockSummaryData.getHeight(); parentBlockSignature = blockSummaryData.getSignature(); + + /* Potential future consensus change: only comparing the same number of blocks. + if (parentHeight >= maxHeight) + break; + */ } return cumulativeWeight; diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 47182119..06850a1b 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -313,12 +313,14 @@ public class Synchronizer { List ourBlockSummaries = repository.getBlockRepository().getBlockSummaries(commonBlockHeight + 1, ourLatestBlockData.getHeight()); // Populate minter account levels for both lists of block summaries - populateBlockSummariesMinterLevels(repository, peerBlockSummaries); populateBlockSummariesMinterLevels(repository, ourBlockSummaries); + populateBlockSummariesMinterLevels(repository, peerBlockSummaries); + + final int mutualHeight = commonBlockHeight - 1 + Math.min(ourBlockSummaries.size(), peerBlockSummaries.size()); // Calculate cumulative chain weights of both blockchain subsets, from common block to highest mutual block. - BigInteger ourChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, ourBlockSummaries); - BigInteger peerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, peerBlockSummaries); + BigInteger ourChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, ourBlockSummaries, mutualHeight); + BigInteger peerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockSig, peerBlockSummaries, mutualHeight); NumberFormat formatter = new DecimalFormat("0.###E0"); LOGGER.debug(String.format("Our chain weight: %s, peer's chain weight: %s (higher is better)", formatter.format(ourChainWeight), formatter.format(peerChainWeight))); diff --git a/src/test/java/org/qortal/test/ChainWeightTests.java b/src/test/java/org/qortal/test/ChainWeightTests.java index c580f30c..b02c155e 100644 --- a/src/test/java/org/qortal/test/ChainWeightTests.java +++ b/src/test/java/org/qortal/test/ChainWeightTests.java @@ -103,8 +103,10 @@ public class ChainWeightTests extends Common { populateBlockSummariesMinterLevels(repository, shorterChain); populateBlockSummariesMinterLevels(repository, longerChain); - BigInteger shorterChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockGeneratorKey, shorterChain); - BigInteger longerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockGeneratorKey, longerChain); + final int mutualHeight = commonBlockHeight - 1 + Math.min(shorterChain.size(), longerChain.size()); + + BigInteger shorterChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockGeneratorKey, shorterChain, mutualHeight); + BigInteger longerChainWeight = Block.calcChainWeight(commonBlockHeight, commonBlockGeneratorKey, longerChain, mutualHeight); assertEquals("longer chain should have greater weight", 1, longerChainWeight.compareTo(shorterChainWeight)); } From 16397852ae3df5ed9496f86e3a53ffdb71b9c7a0 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 4 Nov 2020 10:01:20 +0000 Subject: [PATCH 38/55] Add synchronization around updating trim heights to prevent deadlock/rollback --- .../repository/hsqldb/HSQLDBATRepository.java | 16 ++++++++++------ .../repository/hsqldb/HSQLDBBlockRepository.java | 16 ++++++++++------ .../repository/hsqldb/HSQLDBRepository.java | 1 + 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index d2463c80..b5779c65 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -415,13 +415,17 @@ public class HSQLDBATRepository implements ATRepository { @Override public void setAtTrimHeight(int trimHeight) throws DataException { - String updateSql = "UPDATE DatabaseInfo SET AT_trim_height = ?"; + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET AT_trim_height = ?"; - try { - this.repository.executeCheckedUpdate(updateSql, trimHeight); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to set AT state trim height in repository", e); + try { + this.repository.executeCheckedUpdate(updateSql, trimHeight); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set AT state trim height in repository", e); + } } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index 8d544e0b..de76d17b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -477,13 +477,17 @@ public class HSQLDBBlockRepository implements BlockRepository { @Override public void setOnlineAccountsSignaturesTrimHeight(int trimHeight) throws DataException { - String updateSql = "UPDATE DatabaseInfo SET online_signatures_trim_height = ?"; + // trimHeightsLock is to prevent concurrent update on DatabaseInfo + // that could result in "transaction rollback: serialization failure" + synchronized (this.repository.trimHeightsLock) { + String updateSql = "UPDATE DatabaseInfo SET online_signatures_trim_height = ?"; - try { - this.repository.executeCheckedUpdate(updateSql, trimHeight); - } catch (SQLException e) { - repository.examineException(e); - throw new DataException("Unable to set online accounts signatures trim height in repository", e); + try { + this.repository.executeCheckedUpdate(updateSql, trimHeight); + } catch (SQLException e) { + repository.examineException(e); + throw new DataException("Unable to set online accounts signatures trim height in repository", e); + } } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 8391d7ae..dd53e742 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -60,6 +60,7 @@ public class HSQLDBRepository implements Repository { protected List sqlStatements; protected long sessionId; protected final Map preparedStatementCache = new HashMap<>(); + protected final Object trimHeightsLock = new Object(); private final ATRepository atRepository = new HSQLDBATRepository(this); private final AccountRepository accountRepository = new HSQLDBAccountRepository(this); From ad5050f92ebc65451408bf3de4e046bfdc3941e4 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 4 Nov 2020 15:29:10 +0000 Subject: [PATCH 39/55] Add support for exporting node-local repository data to .script files and corresponding import function --- .../org/qortal/repository/Repository.java | 4 +++ .../repository/hsqldb/HSQLDBRepository.java | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index cbfaab97..527b23f3 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -51,4 +51,8 @@ public interface Repository extends AutoCloseable { public void performPeriodicMaintenance() throws DataException; + public void exportNodeLocalData() throws DataException; + + public void importDataFromFile(String filename) throws DataException; + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index dd53e742..42fd926b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -399,6 +399,32 @@ public class HSQLDBRepository implements Repository { } } + @Override + public void exportNodeLocalData() throws DataException { + try (Statement stmt = this.connection.createStatement()) { + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA WITH COLUMN NAMES TO 'MintingAccounts.script'"); + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA WITH COLUMN NAMES TO 'TradeBotStates.script'"); + LOGGER.info("Exported sensitive/node-local data: minting keys and trade bot states"); + } catch (SQLException e) { + throw new DataException("Unable to export sensitive/node-local data from repository"); + } + } + + @Override + public void importDataFromFile(String filename) throws DataException { + try (Statement stmt = this.connection.createStatement()) { + LOGGER.info(() -> String.format("Importing data into repository from %s", filename)); + + String escapedFilename = stmt.enquoteLiteral(filename); + stmt.execute("PERFORM IMPORT SCRIPT DATA FROM " + escapedFilename + " STOP ON ERROR"); + + LOGGER.info(() -> String.format("Imported data into repository from %s", filename)); + } catch (SQLException e) { + LOGGER.info(() -> String.format("Failed to import data into repository from %s: %s", filename, e.getMessage())); + throw new DataException("Unable to export sensitive/node-local data from repository: " + e.getMessage()); + } + } + /** Returns DB pathname from passed connection URL. If memory DB, returns "mem". */ private static String getDbPathname(String connectionUrl) { Pattern pattern = Pattern.compile("hsqldb:(mem|file):(.*?)(;|$)"); From 41f178bf59893b67c676df9364752cc2692f8a9b Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 4 Nov 2020 15:35:20 +0000 Subject: [PATCH 40/55] Add support for API key security, where X-API-KEY header must match apiKey from settings apiKey in settings is null by default at this point, for backwards compatibility. In the future, the Windows installer could generate a UUID for apiKey. apiKey in settings needs to be at least 8 characters. API calls in the documentation engine are now marked with an open/closed padlock to show where API key might be required. Add support for API key security, where X-API-KEY header must match apiKey from settings apiKey in settings is null by default at this point, for backwards compatibility. In the future, the Windows installer could generate a UUID for apiKey. apiKey in settings needs to be at least 8 characters. API calls in the documentation engine are now marked with an open/closed padlock to show where API key might be required. --- src/main/java/org/qortal/api/Security.java | 15 +++++++++++++-- .../api/resource/AddressesResource.java | 2 ++ .../qortal/api/resource/AdminResource.java | 11 +++++++++++ .../qortal/api/resource/ApiDefinition.java | 10 ++++++++++ .../org/qortal/api/resource/ChatResource.java | 3 +++ .../api/resource/CrossChainResource.java | 19 +++++++++++++++++++ .../qortal/api/resource/PeersResource.java | 6 ++++++ .../java/org/qortal/settings/Settings.java | 8 ++++++++ 8 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/Security.java b/src/main/java/org/qortal/api/Security.java index 2449f781..448f951a 100644 --- a/src/main/java/org/qortal/api/Security.java +++ b/src/main/java/org/qortal/api/Security.java @@ -5,10 +5,20 @@ import java.net.UnknownHostException; import javax.servlet.http.HttpServletRequest; -public class Security { +import org.qortal.settings.Settings; + +public abstract class Security { + + public static final String API_KEY_HEADER = "X-API-KEY"; - // TODO: replace with proper authentication public static void checkApiCallAllowed(HttpServletRequest request) { + String expectedApiKey = Settings.getInstance().getApiKey(); + String passedApiKey = request.getHeader(API_KEY_HEADER); + + if ((expectedApiKey != null && !expectedApiKey.equals(passedApiKey)) || + (passedApiKey != null && !passedApiKey.equals(expectedApiKey))) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); + InetAddress remoteAddr; try { remoteAddr = InetAddress.getByName(request.getRemoteAddr()); @@ -19,4 +29,5 @@ public class Security { if (!remoteAddr.isLoopbackAddress()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); } + } diff --git a/src/main/java/org/qortal/api/resource/AddressesResource.java b/src/main/java/org/qortal/api/resource/AddressesResource.java index 39b4bd71..20e4da5a 100644 --- a/src/main/java/org/qortal/api/resource/AddressesResource.java +++ b/src/main/java/org/qortal/api/resource/AddressesResource.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.math.BigDecimal; @@ -473,6 +474,7 @@ public class AddressesResource { } ) @ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String computePublicize(String rawBytes58) { Security.checkApiCallAllowed(request); diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 6fbadb96..fc761501 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.io.IOException; @@ -133,6 +134,7 @@ public class AdminResource { ) } ) + @SecurityRequirement(name = "apiKey") public NodeStatus status() { Security.checkApiCallAllowed(request); @@ -153,6 +155,7 @@ public class AdminResource { ) } ) + @SecurityRequirement(name = "apiKey") public String shutdown() { Security.checkApiCallAllowed(request); @@ -181,6 +184,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public ActivitySummary summary() { Security.checkApiCallAllowed(request); @@ -226,6 +230,7 @@ public class AdminResource { ) } ) + @SecurityRequirement(name = "apiKey") public Controller.StatsSnapshot getEngineStats() { Security.checkApiCallAllowed(request); @@ -244,6 +249,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public List getMintingAccounts() { Security.checkApiCallAllowed(request); @@ -290,6 +296,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE, ApiError.CANNOT_MINT}) + @SecurityRequirement(name = "apiKey") public String addMintingAccount(String seed58) { Security.checkApiCallAllowed(request); @@ -342,6 +349,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String deleteMintingAccount(String key58) { Security.checkApiCallAllowed(request); @@ -441,6 +449,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.INVALID_HEIGHT, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String orphan(String targetHeightString) { Security.checkApiCallAllowed(request); @@ -482,6 +491,7 @@ public class AdminResource { } ) @ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String forceSync(String targetPeerAddress) { Security.checkApiCallAllowed(request); @@ -527,6 +537,7 @@ public class AdminResource { description = "Requires enough free space to rebuild repository. This will pause your node for a while." ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public void performRepositoryMaintenance() { Security.checkApiCallAllowed(request); diff --git a/src/main/java/org/qortal/api/resource/ApiDefinition.java b/src/main/java/org/qortal/api/resource/ApiDefinition.java index ae7de00c..f9ec7459 100644 --- a/src/main/java/org/qortal/api/resource/ApiDefinition.java +++ b/src/main/java/org/qortal/api/resource/ApiDefinition.java @@ -1,11 +1,17 @@ package org.qortal.api.resource; import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.security.SecuritySchemes; import io.swagger.v3.oas.annotations.tags.Tag; +import org.qortal.api.Security; + @OpenAPIDefinition( info = @Info( title = "Qortal API", description = "NOTE: byte-arrays are encoded in Base58" ), tags = { @@ -30,5 +36,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; }) } ) +@SecuritySchemes({ + @SecurityScheme(name = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic"), + @SecurityScheme(name = "apiKey", type = SecuritySchemeType.APIKEY, in = SecuritySchemeIn.HEADER, paramName = Security.API_KEY_HEADER) +}) public class ApiDefinition { } \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 9a8fc8d5..6ad7d6ea 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; @@ -156,6 +157,7 @@ public class ChatResource { } ) @ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String buildChat(ChatTransactionData transactionData) { Security.checkApiCallAllowed(request); @@ -203,6 +205,7 @@ public class ChatResource { } ) @ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String buildChat(String rawBytes58) { Security.checkApiCallAllowed(request); diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index c8ab6527..9e46b245 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.math.BigDecimal; @@ -155,6 +156,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String buildTrade(CrossChainBuildRequest tradeRequest) { Security.checkApiCallAllowed(request); @@ -250,6 +252,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String buildTradeMessage(CrossChainTradeRequest tradeRequest) { Security.checkApiCallAllowed(request); @@ -333,6 +336,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String buildRedeemMessage(CrossChainSecretRequest secretRequest) { Security.checkApiCallAllowed(request); @@ -404,6 +408,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String buildCancelMessage(CrossChainCancelRequest cancelRequest) { Security.checkApiCallAllowed(request); @@ -459,6 +464,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) { Security.checkApiCallAllowed(request); @@ -485,6 +491,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String deriveP2shB(CrossChainBitcoinTemplateRequest templateRequest) { Security.checkApiCallAllowed(request); @@ -542,6 +549,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) { Security.checkApiCallAllowed(request); @@ -568,6 +576,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) { Security.checkApiCallAllowed(request); @@ -656,6 +665,7 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String refundP2shA(CrossChainBitcoinRefundRequest refundRequest) { Security.checkApiCallAllowed(request); @@ -683,6 +693,7 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String refundP2shB(CrossChainBitcoinRefundRequest refundRequest) { Security.checkApiCallAllowed(request); @@ -793,6 +804,7 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) { Security.checkApiCallAllowed(request); @@ -821,6 +833,7 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) { Security.checkApiCallAllowed(request); @@ -935,6 +948,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PRIVATE_KEY}) + @SecurityRequirement(name = "apiKey") public String getBitcoinWalletBalance(String xprv58) { Security.checkApiCallAllowed(request); @@ -969,6 +983,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") public String sendBitcoin(BitcoinSendRequest bitcoinSendRequest) { Security.checkApiCallAllowed(request); @@ -1019,6 +1034,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public List getTradeBotStates() { Security.checkApiCallAllowed(request); @@ -1049,6 +1065,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { Security.checkApiCallAllowed(request); @@ -1104,6 +1121,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) { Security.checkApiCallAllowed(request); @@ -1168,6 +1186,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public String tradeBotDelete(String tradePrivateKey58) { Security.checkApiCallAllowed(request); diff --git a/src/main/java/org/qortal/api/resource/PeersResource.java b/src/main/java/org/qortal/api/resource/PeersResource.java index a66fef4a..70f0e3e9 100644 --- a/src/main/java/org/qortal/api/resource/PeersResource.java +++ b/src/main/java/org/qortal/api/resource/PeersResource.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.net.InetSocketAddress; @@ -131,6 +132,7 @@ public class PeersResource { ) } ) + @SecurityRequirement(name = "apiKey") public ExecuteProduceConsume.StatsSnapshot getEngineStats() { Security.checkApiCallAllowed(request); @@ -168,6 +170,7 @@ public class PeersResource { @ApiErrors({ ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE }) + @SecurityRequirement(name = "apiKey") public String addPeer(String address) { Security.checkApiCallAllowed(request); @@ -222,6 +225,7 @@ public class PeersResource { @ApiErrors({ ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE }) + @SecurityRequirement(name = "apiKey") public String removePeer(String address) { Security.checkApiCallAllowed(request); @@ -257,6 +261,7 @@ public class PeersResource { @ApiErrors({ ApiError.REPOSITORY_ISSUE }) + @SecurityRequirement(name = "apiKey") public String removeKnownPeers(String address) { Security.checkApiCallAllowed(request); @@ -296,6 +301,7 @@ public class PeersResource { } ) @ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") public List commonBlock(String targetPeerAddress) { Security.checkApiCallAllowed(request); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 1a989c2e..1d33dcb7 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -65,6 +65,7 @@ public class Settings { "::1", "127.0.0.1" }; private Boolean apiRestricted; + private String apiKey = null; private boolean apiLoggingEnabled = false; private boolean apiDocumentationEnabled = false; // Both of these need to be set for API to use SSL @@ -275,6 +276,9 @@ public class Settings { // Validation goes here if (this.minBlockchainPeers < 1) throwValidationError("minBlockchainPeers must be at least 1"); + + if (this.apiKey != null && this.apiKey.trim().length() < 8) + throwValidationError("apiKey must be at least 8 characters"); } // Getters / setters @@ -323,6 +327,10 @@ public class Settings { return !BlockChain.getInstance().isTestChain(); } + public String getApiKey() { + return this.apiKey; + } + public boolean isApiLoggingEnabled() { return this.apiLoggingEnabled; } From 8c9f68a9c37a6267fbb01b49f8dc155f8806d00b Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 4 Nov 2020 15:35:42 +0000 Subject: [PATCH 41/55] Add API calls for exporting node-local repository data & corresponding import to/from local files --- .../qortal/api/resource/AdminResource.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index fc761501..88b15e60 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -530,6 +530,87 @@ public class AdminResource { } } + @GET + @Path("/repository") + @Operation( + summary = "Export sensitive/node-local data from repository.", + description = "Exports data to .script files on local machine" + ) + @ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String exportRepository() { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + repository.exportNodeLocalData(); + return "true"; + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform export + return "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/repository") + @Operation( + summary = "Import data into repository.", + description = "Imports data from file on local machine. Filename is forced to 'import.script' if apiKey is not set.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", example = "MintingAccounts.script" + ) + ) + ), + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String importRepository(String filename) { + Security.checkApiCallAllowed(request); + + // Hard-coded because it's too dangerous to allow user-supplied filenames in weaker security contexts + if (Settings.getInstance().getApiKey() == null) + filename = "import.script"; + + try (final Repository repository = RepositoryManager.getRepository()) { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + repository.importDataFromFile(filename); + repository.saveChanges(); + + return "true"; + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform import + return "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @DELETE @Path("/repository") @Operation( From b3f859f290f74ab4876efb96e4a897cf826ae3d8 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 4 Nov 2020 15:48:52 +0000 Subject: [PATCH 42/55] Don't use WITH COLUMN NAMES when exporting data from repository into local file --- .../java/org/qortal/repository/hsqldb/HSQLDBRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 42fd926b..fc0ff259 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -402,8 +402,8 @@ public class HSQLDBRepository implements Repository { @Override public void exportNodeLocalData() throws DataException { try (Statement stmt = this.connection.createStatement()) { - stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA WITH COLUMN NAMES TO 'MintingAccounts.script'"); - stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA WITH COLUMN NAMES TO 'TradeBotStates.script'"); + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA TO 'MintingAccounts.script'"); + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA TO 'TradeBotStates.script'"); LOGGER.info("Exported sensitive/node-local data: minting keys and trade bot states"); } catch (SQLException e) { throw new DataException("Unable to export sensitive/node-local data from repository"); From 20777363cf335b21c0cd332206021b7b5171fc7e Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 4 Nov 2020 20:07:30 +0000 Subject: [PATCH 43/55] Split AT state storage into two HSQLDB table for better management This involves a database reshape, but before this happens the node-local data is exported to local files, giving the user the option to use a bootstrap file instead of waiting. --- .../repository/hsqldb/HSQLDBATRepository.java | 64 ++- .../hsqldb/HSQLDBDatabaseUpdates.java | 68 ++- .../repository/hsqldb/HSQLDBRepository.java | 2 +- .../org/qortal/test/at/AtRepositoryTests.java | 426 ++++++++++++++++++ 4 files changed, 537 insertions(+), 23 deletions(-) create mode 100644 src/test/java/org/qortal/test/at/AtRepositoryTests.java diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index b5779c65..0f7c28a2 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -250,7 +250,8 @@ public class HSQLDBATRepository implements ATRepository { public ATStateData getATStateAtHeight(String atAddress, int height) throws DataException { String sql = "SELECT state_data, state_hash, fees, is_initial " + "FROM ATStates " - + "WHERE AT_address = ? AND height = ? " + + "LEFT OUTER JOIN ATStatesData USING (AT_address, height) " + + "WHERE ATStates.AT_address = ? AND ATStates.height = ? " + "LIMIT 1"; try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress, height)) { @@ -272,10 +273,11 @@ public class HSQLDBATRepository implements ATRepository { public ATStateData getLatestATState(String atAddress) throws DataException { String sql = "SELECT height, state_data, state_hash, fees, is_initial " + "FROM ATStates " - + "WHERE AT_address = ? " - // AT_address then height so the compound primary key is used as an index - // Both must be the same direction also - + "ORDER BY AT_address DESC, height DESC " + + "JOIN ATStatesData USING (AT_address, height) " + + "WHERE ATStates.AT_address = ? " + // Order by AT_address and height to use compound primary key as index + // Both must be the same direction (DESC) also + + "ORDER BY ATStates.AT_address DESC, ATStates.height DESC " + "LIMIT 1 "; try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress)) { @@ -306,16 +308,17 @@ public class HSQLDBATRepository implements ATRepository { + "CROSS JOIN LATERAL(" + "SELECT height, state_data, state_hash, fees, is_initial " + "FROM ATStates " + + "JOIN ATStatesData USING (AT_address, height) " + "WHERE ATStates.AT_address = ATs.AT_address "); if (minimumFinalHeight != null) { - sql.append("AND height >= ? "); + sql.append("AND ATStates.height >= ? "); bindParams.add(minimumFinalHeight); } - // AT_address then height so the compound primary key is used as an index - // Both must be the same direction also - sql.append("ORDER BY AT_address DESC, height DESC " + // Order by AT_address and height to use compound primary key as index + // Both must be the same direction (DESC) also + sql.append("ORDER BY ATStates.AT_address DESC, ATStates.height DESC " + "LIMIT 1 " + ") AS FinalATStates " + "WHERE code_hash = ? "); @@ -337,7 +340,7 @@ public class HSQLDBATRepository implements ATRepository { bindParams.add(rawExpectedValue); } - sql.append(" ORDER BY height "); + sql.append(" ORDER BY FinalATStates.height "); if (reverse != null && reverse) sql.append("DESC"); @@ -431,7 +434,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public void prepareForAtStateTrimming() throws DataException { - // Rebuild cache of latest, non-finished AT states that we can't trim + // Rebuild cache of latest AT states that we can't trim String deleteSql = "DELETE FROM LatestATStates"; try { this.repository.executeCheckedUpdate(deleteSql); @@ -463,13 +466,12 @@ public class HSQLDBATRepository implements ATRepository { // We're often called so no need to trim all states in one go. // Limit updates to reduce CPU and memory load. - String sql = "UPDATE ATStates SET state_data = NULL " - + "WHERE state_data IS NOT NULL " - + "AND height BETWEEN ? AND ? " + String sql = "DELETE FROM ATStatesData " + + "WHERE height BETWEEN ? AND ? " + "AND NOT EXISTS(" + "SELECT TRUE FROM LatestATStates " - + "WHERE LatestATStates.AT_address = ATStates.AT_address " - + "AND LatestATStates.height = ATStates.height" + + "WHERE LatestATStates.AT_address = ATStatesData.AT_address " + + "AND LatestATStates.height = ATStatesData.height" + ") " + "LIMIT ?"; @@ -487,23 +489,44 @@ public class HSQLDBATRepository implements ATRepository { if (atStateData.getStateHash() == null || atStateData.getHeight() == null) throw new IllegalArgumentException("Refusing to save partial AT state into repository!"); - HSQLDBSaver saveHelper = new HSQLDBSaver("ATStates"); + HSQLDBSaver atStatesSaver = new HSQLDBSaver("ATStates"); - saveHelper.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) - .bind("state_data", atStateData.getStateData()).bind("state_hash", atStateData.getStateHash()) + atStatesSaver.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) + .bind("state_hash", atStateData.getStateHash()) .bind("fees", atStateData.getFees()).bind("is_initial", atStateData.isInitial()); try { - saveHelper.execute(this.repository); + atStatesSaver.execute(this.repository); } catch (SQLException e) { throw new DataException("Unable to save AT state into repository", e); } + + if (atStateData.getStateData() != null) { + HSQLDBSaver atStatesDataSaver = new HSQLDBSaver("ATStatesData"); + + atStatesDataSaver.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) + .bind("state_data", atStateData.getStateData()); + + try { + atStatesDataSaver.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save AT state data into repository", e); + } + } else { + try { + this.repository.delete("ATStatesData", "AT_address = ? AND height = ?", + atStateData.getATAddress(), atStateData.getHeight()); + } catch (SQLException e) { + throw new DataException("Unable to delete AT state data from repository", e); + } + } } @Override public void delete(String atAddress, int height) throws DataException { try { this.repository.delete("ATStates", "AT_address = ? AND height = ?", atAddress, height); + this.repository.delete("ATStatesData", "AT_address = ? AND height = ?", atAddress, height); } catch (SQLException e) { throw new DataException("Unable to delete AT state from repository", e); } @@ -513,6 +536,7 @@ public class HSQLDBATRepository implements ATRepository { public void deleteATStates(int height) throws DataException { try { this.repository.delete("ATStates", "height = ?", height); + this.repository.delete("ATStatesData", "height = ?", height); } catch (SQLException e) { throw new DataException("Unable to delete AT states from repository", e); } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 3255c045..72d54111 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -660,9 +660,10 @@ public class HSQLDBDatabaseUpdates { break; case 25: + // DISABLED: improved version in case 30! // Remove excess created_when from ATStates - stmt.execute("ALTER TABLE ATStates DROP created_when"); - stmt.execute("CREATE INDEX ATStateHeightIndex on ATStates (height)"); + // stmt.execute("ALTER TABLE ATStates DROP created_when"); + // stmt.execute("CREATE INDEX ATStateHeightIndex on ATStates (height)"); break; case 26: @@ -691,6 +692,69 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CHECKPOINT"); break; + case 30: + // Split AT state data off to new table for better performance/management. + + if (!"mem".equals(HSQLDBRepository.getDbPathname(connection.getMetaData().getURL()))) { + // First, backup node-local data in case user wants to avoid long reshape and use bootstrap instead + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA TO 'MintingAccounts.script'"); + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA TO 'TradeBotStates.script'"); + LOGGER.info("Exported sensitive/node-local data: minting keys and trade bot states"); + LOGGER.info("If following reshape takes too long, use bootstrap and import node-local data using API's POST /admin/repository"); + } + + // Create new AT-states table without full state data + stmt.execute("CREATE TABLE ATStatesNew (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " + + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, " + + "PRIMARY KEY (AT_address, height), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + stmt.execute("SET TABLE ATStatesNew NEW SPACE"); + stmt.execute("CHECKPOINT"); + + ResultSet resultSet = stmt.executeQuery("SELECT height FROM Blocks ORDER BY height DESC LIMIT 1"); + final int blockchainHeight = resultSet.next() ? resultSet.getInt(1) : 0; + final int heightStep = 100; + + LOGGER.info("Rebuilding AT state summaries in repository - this might take a while... (approx. 2 mins on high-spec)"); + for (int minHeight = 1; minHeight < blockchainHeight; minHeight += heightStep) { + stmt.execute("INSERT INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial " + + "FROM ATStates " + + "WHERE height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) + + ")"); + stmt.execute("COMMIT"); + } + stmt.execute("CHECKPOINT"); + + LOGGER.info("Rebuilding AT states height index in repository - this might take about 3x longer..."); + stmt.execute("CREATE INDEX ATStatesHeightIndex ON ATStatesNew (height)"); + stmt.execute("CHECKPOINT"); + + stmt.execute("CREATE TABLE ATStatesData (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_data ATState NOT NULL, " + + "PRIMARY KEY (height, AT_address), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + stmt.execute("SET TABLE ATStatesData NEW SPACE"); + stmt.execute("CHECKPOINT"); + + LOGGER.info("Rebuilding AT state data in repository - this might take a while... (approx. 2 mins on high-spec)"); + for (int minHeight = 1; minHeight < blockchainHeight; minHeight += heightStep) { + stmt.execute("INSERT INTO ATStatesData (" + + "SELECT AT_address, height, state_data " + + "FROM ATstates " + + "WHERE state_data IS NOT NULL " + + "AND height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) + + ")"); + stmt.execute("COMMIT"); + } + stmt.execute("CHECKPOINT"); + + stmt.execute("DROP TABLE ATStates"); + stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); + stmt.execute("CHECKPOINT"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index fc0ff259..d3815dcc 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -426,7 +426,7 @@ public class HSQLDBRepository implements Repository { } /** Returns DB pathname from passed connection URL. If memory DB, returns "mem". */ - private static String getDbPathname(String connectionUrl) { + /*package*/ static String getDbPathname(String connectionUrl) { Pattern pattern = Pattern.compile("hsqldb:(mem|file):(.*?)(;|$)"); Matcher matcher = pattern.matcher(connectionUrl); diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java new file mode 100644 index 00000000..9d19f0eb --- /dev/null +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -0,0 +1,426 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.List; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; + +public class AtRepositoryTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetATStateAtHeightWithData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + Integer testHeight = 8; + ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetATStateAtHeightWithoutData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + int maxHeight = 8; + Integer testHeight = maxHeight - 2; + + // Trim AT state data + repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().trimAtStates(2, maxHeight, 1000); + + ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNull(atStateData.getStateData()); + } + } + + @Test + public void testGetLatestATStateWithData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight; + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetLatestATStatePostTrimming() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + int maxHeight = blockchainHeight + 100; // more than latest block height + Integer testHeight = blockchainHeight; + + // Trim AT state data + repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().trimAtStates(2, maxHeight, 1000); + + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + + assertEquals(testHeight, atStateData.getHeight()); + // We should always have the latest AT state data available + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetMatchingFinalATStatesWithoutDataValue() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight; + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + + byte[] codeHash = atData.getCodeHash(); + Boolean isFinished = Boolean.FALSE; + Integer dataByteOffset = null; + Long expectedValue = null; + Integer minimumFinalHeight = null; + Integer limit = null; + Integer offset = null; + Boolean reverse = null; + + List atStates = repository.getATRepository().getMatchingFinalATStates( + codeHash, + isFinished, + dataByteOffset, + expectedValue, + minimumFinalHeight, + limit, offset, reverse); + + assertEquals(false, atStates.isEmpty()); + assertEquals(1, atStates.size()); + + ATStateData atStateData = atStates.get(0); + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetMatchingFinalATStatesWithDataValue() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight; + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + + byte[] codeHash = atData.getCodeHash(); + Boolean isFinished = Boolean.FALSE; + Integer dataByteOffset = MachineState.HEADER_LENGTH + 0; + Long expectedValue = 0L; + Integer minimumFinalHeight = null; + Integer limit = null; + Integer offset = null; + Boolean reverse = null; + + List atStates = repository.getATRepository().getMatchingFinalATStates( + codeHash, + isFinished, + dataByteOffset, + expectedValue, + minimumFinalHeight, + limit, offset, reverse); + + assertEquals(false, atStates.isEmpty()); + assertEquals(1, atStates.size()); + + ATStateData atStateData = atStates.get(0); + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testGetBlockATStatesAtHeightWithData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + doDeploy(repository, deployer, creationBytes, fundingAmount); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + Integer testHeight = 8; + List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); + + assertEquals(false, atStates.isEmpty()); + assertEquals(1, atStates.size()); + + ATStateData atStateData = atStates.get(0); + assertEquals(testHeight, atStateData.getHeight()); + // getBlockATStatesAtHeight never returns actual AT state data anyway + assertNull(atStateData.getStateData()); + } + } + + @Test + public void testGetBlockATStatesAtHeightWithoutData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + doDeploy(repository, deployer, creationBytes, fundingAmount); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + int maxHeight = 8; + Integer testHeight = maxHeight - 2; + + // Trim AT state data + repository.getATRepository().prepareForAtStateTrimming(); + repository.getATRepository().trimAtStates(2, maxHeight, 1000); + + List atStates = repository.getATRepository().getBlockATStatesAtHeight(testHeight); + + assertEquals(false, atStates.isEmpty()); + assertEquals(1, atStates.size()); + + ATStateData atStateData = atStates.get(0); + assertEquals(testHeight, atStateData.getHeight()); + // getBlockATStatesAtHeight never returns actual AT state data anyway + assertNull(atStateData.getStateData()); + } + } + + @Test + public void testSaveATStateWithData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight - 2; + ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + + repository.getATRepository().save(atStateData); + + atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + } + } + + @Test + public void testSaveATStateWithoutData() throws DataException { + byte[] creationBytes = buildSimpleAT(); + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Mint a few blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + int blockchainHeight = repository.getBlockRepository().getBlockchainHeight(); + + Integer testHeight = blockchainHeight - 2; + ATStateData atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNotNull(atStateData.getStateData()); + + // Clear data + ATStateData newAtStateData = new ATStateData(atStateData.getATAddress(), + atStateData.getHeight(), + /*StateData*/ null, + atStateData.getStateHash(), + atStateData.getFees(), + atStateData.isInitial()); + repository.getATRepository().save(newAtStateData); + + atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); + + assertEquals(testHeight, atStateData.getHeight()); + assertNull(atStateData.getStateData()); + } + } + + private byte[] buildSimpleAT() { + // Pretend we use 4 values in data segment + int addrCounter = 4; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + +} From 5549eded38ec45dcea4ad5d363ff19d593b645a5 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 5 Nov 2020 09:34:57 +0000 Subject: [PATCH 44/55] Improve/fix use of latest block cache, for more cache hits, faster chain-tip response, etc. --- .../org/qortal/controller/Controller.java | 28 +++- src/test/java/org/qortal/test/BlockTests.java | 127 ++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index fc72475d..77f20caf 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -1225,12 +1225,24 @@ public class Controller extends Thread { final byte[] parentSignature = getBlockSummariesMessage.getParentSignature(); this.stats.getBlockSummariesStats.requests.incrementAndGet(); + // If peer's parent signature matches our latest block signature + // then we can short-circuit with an empty response + BlockData chainTip = getChainTip(); + if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { + Message blockSummariesMessage = new BlockSummariesMessage(Collections.emptyList()); + blockSummariesMessage.setId(message.getId()); + if (!peer.sendMessage(blockSummariesMessage)) + peer.disconnect("failed to send block summaries"); + + return; + } + List blockSummaries = new ArrayList<>(); // Attempt to serve from our cache of latest blocks synchronized (this.latestBlocks) { blockSummaries = this.latestBlocks.stream() - .dropWhile(cachedBlockData -> Arrays.equals(cachedBlockData.getSignature(), parentSignature)) + .dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature)) .map(BlockSummaryData::new) .collect(Collectors.toList()); } @@ -1268,12 +1280,24 @@ public class Controller extends Thread { final byte[] parentSignature = getSignaturesMessage.getParentSignature(); this.stats.getBlockSignaturesV2Stats.requests.incrementAndGet(); + // If peer's parent signature matches our latest block signature + // then we can short-circuit with an empty response + BlockData chainTip = getChainTip(); + if (chainTip != null && Arrays.equals(parentSignature, chainTip.getSignature())) { + Message signaturesMessage = new SignaturesMessage(Collections.emptyList()); + signaturesMessage.setId(message.getId()); + if (!peer.sendMessage(signaturesMessage)) + peer.disconnect("failed to send signatures (v2)"); + + return; + } + List signatures = new ArrayList<>(); // Attempt to serve from our cache of latest blocks synchronized (this.latestBlocks) { signatures = this.latestBlocks.stream() - .dropWhile(cachedBlockData -> Arrays.equals(cachedBlockData.getSignature(), parentSignature)) + .dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature)) .map(BlockData::getSignature) .collect(Collectors.toList()); } diff --git a/src/test/java/org/qortal/test/BlockTests.java b/src/test/java/org/qortal/test/BlockTests.java index 3e3d0ada..b6d4429d 100644 --- a/src/test/java/org/qortal/test/BlockTests.java +++ b/src/test/java/org/qortal/test/BlockTests.java @@ -1,6 +1,10 @@ package org.qortal.test; +import java.util.Arrays; +import java.util.Deque; +import java.util.LinkedList; import java.util.List; +import java.util.stream.Collectors; import org.junit.Before; import org.junit.Test; @@ -133,6 +137,129 @@ public class BlockTests extends Common { } } + @Test + public void testLatestBlockCacheWithLatestBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + byte[] parentSignature = latestBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(true, childBlocks.isEmpty()); + } + } + + @Test + public void testLatestBlockCacheWithPenultimateBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + BlockData penultimateBlock = repository.getBlockRepository().fromHeight(latestBlock.getHeight() - 1); + byte[] parentSignature = penultimateBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(false, childBlocks.isEmpty()); + assertEquals(1, childBlocks.size()); + + BlockData expectedBlock = latestBlock; + BlockData actualBlock = childBlocks.get(0); + assertArrayEquals(expectedBlock.getSignature(), actualBlock.getSignature()); + } + } + + @Test + public void testLatestBlockCacheWithMiddleBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + int tipOffset = 5; + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + BlockData parentBlock = repository.getBlockRepository().fromHeight(latestBlock.getHeight() - tipOffset); + byte[] parentSignature = parentBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(false, childBlocks.isEmpty()); + assertEquals(tipOffset, childBlocks.size()); + + BlockData expectedFirstBlock = repository.getBlockRepository().fromHeight(parentBlock.getHeight() + 1); + BlockData actualFirstBlock = childBlocks.get(0); + assertArrayEquals(expectedFirstBlock.getSignature(), actualFirstBlock.getSignature()); + + BlockData expectedLastBlock = latestBlock; + BlockData actualLastBlock = childBlocks.get(childBlocks.size() - 1); + assertArrayEquals(expectedLastBlock.getSignature(), actualLastBlock.getSignature()); + } + } + + @Test + public void testLatestBlockCacheWithFirstBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + int tipOffset = latestBlockCache.size(); + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + BlockData parentBlock = repository.getBlockRepository().fromHeight(latestBlock.getHeight() - tipOffset); + byte[] parentSignature = parentBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(false, childBlocks.isEmpty()); + assertEquals(tipOffset, childBlocks.size()); + + BlockData expectedFirstBlock = repository.getBlockRepository().fromHeight(parentBlock.getHeight() + 1); + BlockData actualFirstBlock = childBlocks.get(0); + assertArrayEquals(expectedFirstBlock.getSignature(), actualFirstBlock.getSignature()); + + BlockData expectedLastBlock = latestBlock; + BlockData actualLastBlock = childBlocks.get(childBlocks.size() - 1); + assertArrayEquals(expectedLastBlock.getSignature(), actualLastBlock.getSignature()); + } + } + + @Test + public void testLatestBlockCacheWithNoncachedBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + Deque latestBlockCache = buildLatestBlockCache(repository, 20); + + int tipOffset = latestBlockCache.size() + 1; // outside of cache + + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + BlockData parentBlock = repository.getBlockRepository().fromHeight(latestBlock.getHeight() - tipOffset); + byte[] parentSignature = parentBlock.getSignature(); + + List childBlocks = findCachedChildBlocks(latestBlockCache, parentSignature); + + assertEquals(true, childBlocks.isEmpty()); + } + } + + private Deque buildLatestBlockCache(Repository repository, int count) throws DataException { + Deque latestBlockCache = new LinkedList<>(); + + // Mint some blocks + for (int h = 0; h < count; ++h) + latestBlockCache.addLast(BlockUtils.mintBlock(repository).getBlockData()); + + // Reduce cache down to latest 10 blocks + while (latestBlockCache.size() > 10) + latestBlockCache.removeFirst(); + + return latestBlockCache; + } + + private List findCachedChildBlocks(Deque latestBlockCache, byte[] parentSignature) { + return latestBlockCache.stream() + .dropWhile(cachedBlockData -> !Arrays.equals(cachedBlockData.getReference(), parentSignature)) + .collect(Collectors.toList()); + } + @Test public void testCommonBlockSearch() { // Given a list of block summaries, trim all trailing summaries after common block From 253a9944381392968aa4466d9166d7c875bfbcba Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 5 Nov 2020 11:08:54 +0000 Subject: [PATCH 45/55] Add API POST /repository/checkpoint call. Renamed GET/POST /admin/repository calls to /admin/repository/data --- .../qortal/api/resource/AdminResource.java | 42 ++++++++++++++++++- .../repository/hsqldb/HSQLDBRepository.java | 2 +- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 88b15e60..f24389bf 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -531,7 +531,7 @@ public class AdminResource { } @GET - @Path("/repository") + @Path("/repository/data") @Operation( summary = "Export sensitive/node-local data from repository.", description = "Exports data to .script files on local machine" @@ -561,7 +561,7 @@ public class AdminResource { } @POST - @Path("/repository") + @Path("/repository/data") @Operation( summary = "Import data into repository.", description = "Imports data from file on local machine. Filename is forced to 'import.script' if apiKey is not set.", @@ -611,6 +611,44 @@ public class AdminResource { } } + @POST + @Path("/repository/checkpoint") + @Operation( + summary = "Checkpoint data in repository.", + description = "Forces repository to checkpoint uncommitted writes.", + responses = { + @ApiResponse( + description = "\"true\"", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String checkpointRepository() { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); + + blockchainLock.lockInterruptibly(); + + try { + repository.checkpoint(true); + repository.saveChanges(); + + return "true"; + } finally { + blockchainLock.unlock(); + } + } catch (InterruptedException e) { + // We couldn't lock blockchain to perform checkpoint + return "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @DELETE @Path("/repository") @Operation( diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index d3815dcc..7c694b53 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -382,7 +382,7 @@ public class HSQLDBRepository implements Repository { try (Statement stmt = this.connection.createStatement()) { stmt.execute(quick ? "CHECKPOINT" : "CHECKPOINT DEFRAG"); } catch (SQLException e) { - throw new DataException("Unable to perform repositor checkpoint"); + throw new DataException("Unable to perform repository checkpoint"); } } From 58ed72058fb16b2c3485089924333cbe0b07999d Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 5 Nov 2020 14:36:14 +0000 Subject: [PATCH 46/55] Another attempt to prevent "serialization failure" during trimming --- src/main/java/org/qortal/repository/ATRepository.java | 5 ++++- src/main/java/org/qortal/repository/BlockRepository.java | 5 ++++- .../org/qortal/repository/hsqldb/HSQLDBATRepository.java | 1 + .../org/qortal/repository/hsqldb/HSQLDBBlockRepository.java | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index dc8dad15..b21a4909 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -90,7 +90,10 @@ public interface ATRepository { /** Returns height of first trimmable AT state. */ public int getAtTrimHeight() throws DataException; - /** Sets new base height for AT state trimming. */ + /** Sets new base height for AT state trimming. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ public void setAtTrimHeight(int trimHeight) throws DataException; /** Hook to allow repository to prepare/cache info for AT state trimming. */ diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index b421a230..937607cf 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -146,7 +146,10 @@ public interface BlockRepository { /** Returns height of first trimmable online accounts signatures. */ public int getOnlineAccountsSignaturesTrimHeight() throws DataException; - /** Sets new base height for trimming online accounts signatures. */ + /** Sets new base height for trimming online accounts signatures. + *

+ * NOTE: performs implicit repository.saveChanges(). + */ public void setOnlineAccountsSignaturesTrimHeight(int trimHeight) throws DataException; /** diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 0f7c28a2..7d01c050 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -425,6 +425,7 @@ public class HSQLDBATRepository implements ATRepository { try { this.repository.executeCheckedUpdate(updateSql, trimHeight); + this.repository.saveChanges(); } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to set AT state trim height in repository", e); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index de76d17b..d9d6ed51 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -484,6 +484,7 @@ public class HSQLDBBlockRepository implements BlockRepository { try { this.repository.executeCheckedUpdate(updateSql, trimHeight); + this.repository.saveChanges(); } catch (SQLException e) { repository.examineException(e); throw new DataException("Unable to set online accounts signatures trim height in repository", e); From 806baa6ae4061f725d1735f13d086769c2920e69 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 6 Nov 2020 11:23:35 +0000 Subject: [PATCH 47/55] Fix API call referenced in DB reshape --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 72d54111..eca8d856 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -700,7 +700,7 @@ public class HSQLDBDatabaseUpdates { stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA TO 'MintingAccounts.script'"); stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA TO 'TradeBotStates.script'"); LOGGER.info("Exported sensitive/node-local data: minting keys and trade bot states"); - LOGGER.info("If following reshape takes too long, use bootstrap and import node-local data using API's POST /admin/repository"); + LOGGER.info("If following reshape takes too long, use bootstrap and import node-local data using API's POST /admin/repository/data"); } // Create new AT-states table without full state data From 1f409235e47189ccae3737614202f82680ff902e Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 9 Nov 2020 10:16:37 +0000 Subject: [PATCH 48/55] Don't rebuild repository or export node-local data during repository build if repository was 'pristine'. Under certain conditions, e.g. non-existent database files, the repository would be created and then immediately be re-created. Not only was this unnecessary, but HSQLDBDatabaseUpdates would attempt to export the node-local data twice, which would cause an error due to existing .script files. The fix is three-pronged: 1. Don't immediately rebuild the repository if it's only just been built 2. Don't export the empty node-local data if repository has only just been built 3. Don't export the node-local data if it's empty --- .../java/org/qortal/block/BlockChain.java | 3 +- .../qortal/repository/RepositoryFactory.java | 2 + .../qortal/repository/RepositoryManager.java | 7 ++++ .../hsqldb/HSQLDBDatabaseUpdates.java | 40 +++++++++++++------ .../hsqldb/HSQLDBRepositoryFactory.java | 8 +++- 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 520f8952..e631f930 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -531,7 +531,8 @@ public class BlockChain { private static void rebuildBlockchain() throws DataException { // (Re)build repository - RepositoryManager.rebuild(); + if (!RepositoryManager.wasPristineAtOpen()) + RepositoryManager.rebuild(); try (final Repository repository = RepositoryManager.getRepository()) { GenesisBlock genesisBlock = GenesisBlock.getInstance(repository); diff --git a/src/main/java/org/qortal/repository/RepositoryFactory.java b/src/main/java/org/qortal/repository/RepositoryFactory.java index 94c9627e..e5b29d1b 100644 --- a/src/main/java/org/qortal/repository/RepositoryFactory.java +++ b/src/main/java/org/qortal/repository/RepositoryFactory.java @@ -2,6 +2,8 @@ package org.qortal.repository; public interface RepositoryFactory { + public boolean wasPristineAtOpen(); + public RepositoryFactory reopen() throws DataException; public Repository getRepository() throws DataException; diff --git a/src/main/java/org/qortal/repository/RepositoryManager.java b/src/main/java/org/qortal/repository/RepositoryManager.java index e3427954..9f5cf239 100644 --- a/src/main/java/org/qortal/repository/RepositoryManager.java +++ b/src/main/java/org/qortal/repository/RepositoryManager.java @@ -8,6 +8,13 @@ public abstract class RepositoryManager { repositoryFactory = newRepositoryFactory; } + public static boolean wasPristineAtOpen() throws DataException { + if (repositoryFactory == null) + throw new DataException("No repository available"); + + return repositoryFactory.wasPristineAtOpen(); + } + public static Repository getRepository() throws DataException { if (repositoryFactory == null) throw new DataException("No repository available"); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index eca8d856..e60616d6 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -18,11 +18,16 @@ public class HSQLDBDatabaseUpdates { /** * Apply any incremental changes to database schema. * + * @return true if database was non-existent/empty, false otherwise * @throws SQLException */ - public static void updateDatabase(Connection connection) throws SQLException { - while (databaseUpdating(connection)) + public static boolean updateDatabase(Connection connection) throws SQLException { + final boolean wasPristine = fetchDatabaseVersion(connection) == 0; + + while (databaseUpdating(connection, wasPristine)) incrementDatabaseVersion(connection); + + return wasPristine; } /** @@ -40,23 +45,21 @@ public class HSQLDBDatabaseUpdates { /** * Fetch current version of database schema. * - * @return int, 0 if no schema yet + * @return database version, or 0 if no schema yet * @throws SQLException */ private static int fetchDatabaseVersion(Connection connection) throws SQLException { - int databaseVersion = 0; - try (Statement stmt = connection.createStatement()) { if (stmt.execute("SELECT version FROM DatabaseInfo")) try (ResultSet resultSet = stmt.getResultSet()) { if (resultSet.next()) - databaseVersion = resultSet.getInt(1); + return resultSet.getInt(1); } } catch (SQLException e) { // empty database } - return databaseVersion; + return 0; } /** @@ -65,7 +68,7 @@ public class HSQLDBDatabaseUpdates { * @return true - if a schema update happened, false otherwise * @throws SQLException */ - private static boolean databaseUpdating(Connection connection) throws SQLException { + private static boolean databaseUpdating(Connection connection, boolean wasPristine) throws SQLException { int databaseVersion = fetchDatabaseVersion(connection); try (Statement stmt = connection.createStatement()) { @@ -695,11 +698,24 @@ public class HSQLDBDatabaseUpdates { case 30: // Split AT state data off to new table for better performance/management. - if (!"mem".equals(HSQLDBRepository.getDbPathname(connection.getMetaData().getURL()))) { + if (!wasPristine && !"mem".equals(HSQLDBRepository.getDbPathname(connection.getMetaData().getURL()))) { // First, backup node-local data in case user wants to avoid long reshape and use bootstrap instead - stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA TO 'MintingAccounts.script'"); - stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA TO 'TradeBotStates.script'"); - LOGGER.info("Exported sensitive/node-local data: minting keys and trade bot states"); + try (ResultSet resultSet = stmt.executeQuery("SELECT COUNT(*) FROM MintingAccounts")) { + int rowCount = resultSet.next() ? resultSet.getInt(1) : 0; + if (rowCount > 0) { + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE MintingAccounts DATA TO 'MintingAccounts.script'"); + LOGGER.info("Exported sensitive/node-local minting keys into MintingAccounts.script"); + } + } + + try (ResultSet resultSet = stmt.executeQuery("SELECT COUNT(*) FROM TradeBotStates")) { + int rowCount = resultSet.next() ? resultSet.getInt(1) : 0; + if (rowCount > 0) { + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA TO 'TradeBotStates.script'"); + LOGGER.info("Exported sensitive/node-local trade-bot states into TradeBotStates.script"); + } + } + LOGGER.info("If following reshape takes too long, use bootstrap and import node-local data using API's POST /admin/repository/data"); } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java index 8561b698..81bf320b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepositoryFactory.java @@ -25,6 +25,7 @@ public class HSQLDBRepositoryFactory implements RepositoryFactory { private String connectionUrl; private HSQLDBPool connectionPool; + private final boolean wasPristine; /** * Constructs new RepositoryFactory using passed connectionUrl. @@ -65,12 +66,17 @@ public class HSQLDBRepositoryFactory implements RepositoryFactory { // Perform DB updates? try (final Connection connection = this.connectionPool.getConnection()) { - HSQLDBDatabaseUpdates.updateDatabase(connection); + this.wasPristine = HSQLDBDatabaseUpdates.updateDatabase(connection); } catch (SQLException e) { throw new DataException("Repository initialization error", e); } } + @Override + public boolean wasPristineAtOpen() { + return this.wasPristine; + } + @Override public RepositoryFactory reopen() throws DataException { return new HSQLDBRepositoryFactory(this.connectionUrl); From 3ef8b81e51e14488aba2ade10dfe6fa0aa12f208 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 10 Nov 2020 15:38:15 +0000 Subject: [PATCH 49/55] Fix incorrect column indexes when fetching frozen AT data --- .../java/org/qortal/repository/hsqldb/HSQLDBATRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 7d01c050..f49da36d 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -50,7 +50,7 @@ public class HSQLDBATRepository implements ATRepository { boolean hadFatalError = resultSet.getBoolean(10); boolean isFrozen = resultSet.getBoolean(11); - Long frozenBalance = resultSet.getLong(11); + Long frozenBalance = resultSet.getLong(12); if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; @@ -118,7 +118,7 @@ public class HSQLDBATRepository implements ATRepository { boolean hadFatalError = resultSet.getBoolean(10); boolean isFrozen = resultSet.getBoolean(11); - Long frozenBalance = resultSet.getLong(11); + Long frozenBalance = resultSet.getLong(12); if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; From a310e751bb113313338d5cd3725e639deaa254f4 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 10 Nov 2020 16:58:24 +0000 Subject: [PATCH 50/55] Fix slow SQL query in HSQLDBATRepository.getBlockATStatesAtHeight() - mostly used during orphaning --- .../org/qortal/repository/hsqldb/HSQLDBATRepository.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index f49da36d..82af283b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -374,9 +374,10 @@ public class HSQLDBATRepository implements ATRepository { @Override public List getBlockATStatesAtHeight(int height) throws DataException { String sql = "SELECT AT_address, state_hash, fees, is_initial " - + "FROM ATStates " - + "LEFT OUTER JOIN ATs USING (AT_address) " - + "WHERE height = ? " + + "FROM ATs " + + "LEFT OUTER JOIN ATStates " + + "ON ATStates.AT_address = ATs.AT_address AND height = ? " + + "WHERE ATStates.AT_address IS NOT NULL " + "ORDER BY created_when ASC"; List atStates = new ArrayList<>(); From 69ec654e4a38c6a8267304577a81b9070550e5eb Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 11 Nov 2020 09:27:25 +0000 Subject: [PATCH 51/55] Bump to version 1.3.7 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1061ddc9..3774fdee 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 1.3.6 + 1.3.7 jar 0.15.5 From 10c3a0c0563f8210bbcd69cf229feacc3d76b253 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 12 Nov 2020 11:27:36 +0000 Subject: [PATCH 52/55] Update AdvancedInstaller config file with v1.3.7 values --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 2a63f092..d2300a0b 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -19,10 +19,10 @@ - + - + @@ -174,7 +174,7 @@ - + From 9e98ce220fddbe60606c99f208f02bb9570cc006 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 12 Nov 2020 11:28:01 +0000 Subject: [PATCH 53/55] Revert SysTray_ru.properties back to UTF8 form --- src/main/resources/i18n/SysTray_ru.properties | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/src/main/resources/i18n/SysTray_ru.properties b/src/main/resources/i18n/SysTray_ru.properties index 9b93213e..f7012034 100644 --- a/src/main/resources/i18n/SysTray_ru.properties +++ b/src/main/resources/i18n/SysTray_ru.properties @@ -1,29 +1,41 @@ -#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) -# SysTray pop-up menu - -APPLYING_UPDATE_AND_RESTARTING = \u00D0\u009F\u00D1\u0080\u00D0\u00B8\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 \u00D0\u00B0\u00D0\u00B2\u00D1\u0082\u00D0\u00BE\u00D0\u00BC\u00D0\u00B0\u00D1\u0082\u00D0\u00B8\u00D1\u0087\u00D0\u00B5\u00D1\u0081\u00D0\u00BA\u00D0\u00BE\u00D0\u00B3\u00D0\u00BE \u00D0\u00BE\u00D0\u00B1\u00D0\u00BD\u00D0\u00BE\u00D0\u00B2\u00D0\u00BB\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D1\u008F \u00D0\u00B8 \u00D0\u00BF\u00D0\u00B5\u00D1\u0080\u00D0\u00B5\u00D0\u00B7\u00D0\u00B0\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D0\u00BA\u00D0\u00B0... - -AUTO_UPDATE = \u00D0\u0090\u00D0\u00B2\u00D1\u0082\u00D0\u00BE\u00D0\u00BC\u00D0\u00B0\u00D1\u0082\u00D0\u00B8\u00D1\u0087\u00D0\u00B5\u00D1\u0081\u00D0\u00BA\u00D0\u00BE\u00D0\u00B5 \u00D0\u00BE\u00D0\u00B1\u00D0\u00BD\u00D0\u00BE\u00D0\u00B2\u00D0\u00BB\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 - -BLOCK_HEIGHT = \u00D0\u0092\u00D1\u008B\u00D1\u0081\u00D0\u00BE\u00D1\u0082\u00D0\u00B0 \u00D0\u00B1\u00D0\u00BB\u00D0\u00BE\u00D0\u00BA\u00D0\u00B0 - -CHECK_TIME_ACCURACY = \u00D0\u009F\u00D1\u0080\u00D0\u00BE\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BA\u00D0\u00B0 \u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00BE\u00D0\u00B3\u00D0\u00BE \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8 - -CONNECTING = \u00D0\u009F\u00D0\u00BE\u00D0\u00B4\u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 - -CONNECTION = \u00D0\u00A1\u00D0\u00BE\u00D0\u00B5\u00D0\u00B4\u00D0\u00B8\u00D0\u00BD\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 - -CONNECTIONS = \u00D0\u00A1\u00D0\u00BE\u00D0\u00B5\u00D0\u00B4\u00D0\u00B8\u00D0\u00BD\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B9 - -MINTING_DISABLED = \u00D0\u00A7\u00D0\u00B5\u00D0\u00BA\u00D0\u00B0\u00D0\u00BD\u00D0\u00BA\u00D0\u00B0 \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087\u00D0\u00B5\u00D0\u00BD\u00D0\u00B0 - -MINTING_ENABLED = \u00D0\u00A7\u00D0\u00B5\u00D0\u00BA\u00D0\u00B0\u00D0\u00BD\u00D0\u00BA\u00D0\u00B0 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00BD\u00D0\u00B0 - -# Nagging about lack of NTP time sync -NTP_NAG_CAPTION = \u00D0\u00A7\u00D0\u00B0\u00D1\u0081\u00D1\u008B \u00D0\u00BA\u00D0\u00BE\u00D0\u00BC\u00D0\u00BF\u00D1\u008C\u00D1\u008E\u00D1\u0082\u00D0\u00B5\u00D1\u0080\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D1\u008B! - -NTP_NAG_TEXT_UNIX = \u00D0\u00A3\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D0\u00BD\u00D0\u00BE\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5 \u00D1\u0081\u00D0\u00BB\u00D1\u0083\u00D0\u00B6\u00D0\u00B1\u00D1\u0083 NTP, \u00D1\u0087\u00D1\u0082\u00D0\u00BE\u00D0\u00B1\u00D1\u008B \u00D0\u00BF\u00D0\u00BE\u00D0\u00BB\u00D1\u0083\u00D1\u0087\u00D0\u00B8\u00D1\u0082\u00D1\u008C \u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00BE\u00D0\u00B5 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D1\u008F - -OPEN_UI = \u00D0\u009E\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008C \u00D0\u00BF\u00D0\u00BE\u00D0\u00BB\u00D1\u008C\u00D0\u00B7\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D1\u0081\u00D0\u00BA\u00D0\u00B8\u00D0\u00B9 \u00D0\u00B8\u00D0\u00BD\u00D1\u0082\u00D0\u00B5\u00D1\u0080\u00D1\u0084\u00D0\u00B5\u00D0\u00B9\u00D1\u0081 - -SYNCHRONIZING_CLOCK = \u00D0\u009F\u00D1\u0080\u00D0\u00BE\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BA\u00D0\u00B0 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8 +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# SysTray pop-up menu + +APPLYING_UPDATE_AND_RESTARTING = Применение автоматического обновления и перезапуска... + +AUTO_UPDATE = Автоматическое обновление + +BLOCK_HEIGHT = Высота блока + +CHECK_TIME_ACCURACY = Проверка точного времени + +CONNECTING = Подключение + +CONNECTION = Соединение + +CONNECTIONS = Соединений + +CREATING_BACKUP_OF_DB_FILES = Создание резервной копии файлов базы данных... + +DB_BACKUP = Резервное копирование базы данных + +EXIT = Выход + +MINTING_DISABLED = Чеканка отключена + +MINTING_ENABLED = Чеканка активна + +# Nagging about lack of NTP time sync +NTP_NAG_CAPTION = Часы компьютера неточны! + +NTP_NAG_TEXT_UNIX = Установите службу NTP, чтобы получить точное время + +NTP_NAG_TEXT_WINDOWS = Выберите "Синхронизация времени" из меню, чтобы исправить + +OPEN_UI = Открыть пользовательский интерфейс + +SYNCHRONIZE_CLOCK = Синхронизировать время + +SYNCHRONIZING_BLOCKCHAIN = Синхронизация цепи + +SYNCHRONIZING_CLOCK = Проверка времени From 2e8f58bb2f066bda35e0495fbd5e2154ca1cb011 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 12 Nov 2020 11:34:25 +0000 Subject: [PATCH 54/55] ApiError_ru.properties & TransactionValidity_ru.properties converted to UTF8 for easier management --- .../resources/i18n/ApiError_ru.properties | 110 +++--- .../i18n/TransactionValidity_ru.properties | 340 +++++++++--------- 2 files changed, 233 insertions(+), 217 deletions(-) diff --git a/src/main/resources/i18n/ApiError_ru.properties b/src/main/resources/i18n/ApiError_ru.properties index 014fc4ad..e67be901 100644 --- a/src/main/resources/i18n/ApiError_ru.properties +++ b/src/main/resources/i18n/ApiError_ru.properties @@ -1,53 +1,57 @@ -#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) -# Keys are from api.ApiError enum - -ADDRESS_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0083\u00D1\u0087\u00D0\u00B5\u00D1\u0082\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00B7\u00D0\u00B0\u00D0\u00BF\u00D0\u00B8\u00D1\u0081\u00D1\u008C - -# Blocks -BLOCK_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B1\u00D0\u00BB\u00D0\u00BE\u00D0\u00BA - -CANNOT_MINT = \u00D0\u00B0\u00D0\u00BA\u00D0\u00BA\u00D0\u00B0\u00D1\u0083\u00D0\u00BD\u00D1\u0082 \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BC\u00D0\u00BE\u00D0\u00B6\u00D0\u00B5\u00D1\u0082 \u00D1\u0087\u00D0\u00B5\u00D0\u00BA\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u0082\u00D1\u008C - -GROUP_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00B0 - -INVALID_ADDRESS = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B0\u00D0\u00B4\u00D1\u0080\u00D0\u00B5\u00D1\u0081 - -# Assets -INVALID_ASSET_ID = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0 - -INVALID_CRITERIA = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B5 \u00D0\u00BA\u00D1\u0080\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D1\u0080\u00D0\u00B8\u00D0\u00B8 \u00D0\u00BF\u00D0\u00BE\u00D0\u00B8\u00D1\u0081\u00D0\u00BA\u00D0\u00B0 - -INVALID_DATA = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B5 \u00D0\u00B4\u00D0\u00B0\u00D0\u00BD\u00D0\u00BD\u00D1\u008B\u00D0\u00B5 - -INVALID_HEIGHT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D0\u00B2\u00D1\u008B\u00D1\u0081\u00D0\u00BE\u00D1\u0082\u00D0\u00B0 \u00D0\u00B1\u00D0\u00BB\u00D0\u00BE\u00D0\u00BA\u00D0\u00B0 - -INVALID_NETWORK_ADDRESS = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D1\u0081\u00D0\u00B5\u00D1\u0082\u00D0\u00B5\u00D0\u00B2\u00D0\u00BE\u00D0\u00B9 \u00D0\u00B0\u00D0\u00B4\u00D1\u0080\u00D0\u00B5\u00D1\u0081 - -INVALID_ORDER_ID = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7\u00D0\u00B0 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0 - -INVALID_PRIVATE_KEY = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D1\u0080\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 - -INVALID_PUBLIC_KEY = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 - -INVALID_REFERENCE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0081\u00D1\u008B\u00D0\u00BB\u00D0\u00BA\u00D0\u00B0 - -# Validation -INVALID_SIGNATURE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00BF\u00D0\u00BE\u00D0\u00B4\u00D0\u00BF\u00D0\u00B8\u00D1\u0081\u00D1\u008C - -JSON = \u00D0\u00BD\u00D0\u00B5 \u00D1\u0083\u00D0\u00B4\u00D0\u00B0\u00D0\u00BB\u00D0\u00BE\u00D1\u0081\u00D1\u008C \u00D1\u0080\u00D0\u00B0\u00D0\u00B7\u00D0\u00BE\u00D0\u00B1\u00D1\u0080\u00D0\u00B0\u00D1\u0082\u00D1\u008C \u00D1\u0081\u00D0\u00BE\u00D0\u00BE\u00D0\u00B1\u00D1\u0089\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 json - -NAME_UNKNOWN = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00BE - -ORDER_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7\u00D0\u00B0 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0 - -PUBLIC_KEY_NOT_FOUND = \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BD\u00D0\u00B0\u00D0\u00B9\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD - -REPOSITORY_ISSUE = \u00D0\u00BE\u00D1\u0088\u00D0\u00B8\u00D0\u00B1\u00D0\u00BA\u00D0\u00B0 \u00D1\u0080\u00D0\u00B5\u00D0\u00BF\u00D0\u00BE\u00D0\u00B7\u00D0\u00B8\u00D1\u0082\u00D0\u00BE\u00D1\u0080\u00D0\u00B8\u00D1\u008F - -TRANSACTION_INVALID = \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00B0: %s (%s) - -TRANSACTION_UNKNOWN = \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00B0 - -TRANSFORMATION_ERROR = \u00D0\u00BD\u00D0\u00B5 \u00D1\u0083\u00D0\u00B4\u00D0\u00B0\u00D0\u00BB\u00D0\u00BE\u00D1\u0081\u00D1\u008C \u00D0\u00BF\u00D1\u0080\u00D0\u00B5\u00D0\u00BE\u00D0\u00B1\u00D1\u0080\u00D0\u00B0\u00D0\u00B7\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D1\u0082\u00D1\u008C JSON \u00D0\u00B2 \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008E - -UNAUTHORIZED = \u00D0\u00B2\u00D1\u008B\u00D0\u00B7\u00D0\u00BE\u00D0\u00B2 API \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B0\u00D0\u00B2\u00D1\u0082\u00D0\u00BE\u00D1\u0080\u00D0\u00B8\u00D0\u00B7\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D0\u00BD +#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) +# Keys are from api.ApiError enum + +ADDRESS_UNKNOWN = неизвестная учетная запись + +BLOCKCHAIN_NEEDS_SYNC = блокчейн должен сначала синхронизироваться + +# Blocks +BLOCK_UNKNOWN = неизвестный блок + +CANNOT_MINT = аккаунт не может чеканить + +GROUP_UNKNOWN = неизвестная группа + +INVALID_ADDRESS = неизвестный адрес + +# Assets +INVALID_ASSET_ID = неверный идентификатор актива + +INVALID_CRITERIA = неверные критерии поиска + +INVALID_DATA = неверные данные + +INVALID_HEIGHT = недопустимая высота блока + +INVALID_NETWORK_ADDRESS = неверный сетевой адрес + +INVALID_ORDER_ID = неверный идентификатор заказа актива + +INVALID_PRIVATE_KEY = неверный приватный ключ + +INVALID_PUBLIC_KEY = недействительный открытый ключ + +INVALID_REFERENCE = неверная ссылка + +# Validation +INVALID_SIGNATURE = недействительная подпись + +JSON = не удалось разобрать сообщение json + +NAME_UNKNOWN = имя неизвестно + +NON_PRODUCTION = этот вызов API не разрешен для производственных систем + +ORDER_UNKNOWN = неизвестный идентификатор заказа актива + +PUBLIC_KEY_NOT_FOUND = открытый ключ не найден + +REPOSITORY_ISSUE = ошибка репозитория + +TRANSACTION_INVALID = транзакция недействительна: %s (%s) + +TRANSACTION_UNKNOWN = транзакция неизвестна + +TRANSFORMATION_ERROR = не удалось преобразовать JSON в транзакцию + +UNAUTHORIZED = вызов API не авторизован diff --git a/src/main/resources/i18n/TransactionValidity_ru.properties b/src/main/resources/i18n/TransactionValidity_ru.properties index 40112726..c2dbe5df 100644 --- a/src/main/resources/i18n/TransactionValidity_ru.properties +++ b/src/main/resources/i18n/TransactionValidity_ru.properties @@ -1,164 +1,176 @@ - -ACCOUNT_ALREADY_EXISTS = \u00D0\u00B0\u00D0\u00BA\u00D0\u00BA\u00D0\u00B0\u00D1\u0083\u00D0\u00BD\u00D1\u0082 \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -ACCOUNT_CANNOT_REWARD_SHARE = \u00D0\u00B0\u00D0\u00BA\u00D0\u00BA\u00D0\u00B0\u00D1\u0083\u00D0\u00BD\u00D1\u0082 \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BC\u00D0\u00BE\u00D0\u00B6\u00D0\u00B5\u00D1\u0082 \u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B8\u00D1\u0082\u00D1\u008C\u00D1\u0081\u00D1\u008F \u00D0\u00B2\u00D0\u00BE\u00D0\u00B7\u00D0\u00BD\u00D0\u00B0\u00D0\u00B3\u00D1\u0080\u00D0\u00B0\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5\u00D0\u00BC - -ALREADY_GROUP_ADMIN = \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00B0\u00D0\u00B4\u00D0\u00BC\u00D0\u00B8\u00D0\u00BD\u00D0\u00B8\u00D1\u0081\u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -ALREADY_GROUP_MEMBER = \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0087\u00D0\u00BB\u00D0\u00B5\u00D0\u00BD \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -ALREADY_VOTED_FOR_THAT_OPTION = \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D0\u00B3\u00D0\u00BE\u00D0\u00BB\u00D0\u00BE\u00D1\u0081\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D0\u00BB\u00D0\u00B8 \u00D0\u00B7\u00D0\u00B0 \u00D1\u008D\u00D1\u0082\u00D0\u00BE\u00D1\u0082 \u00D0\u00B2\u00D0\u00B0\u00D1\u0080\u00D0\u00B8\u00D0\u00B0\u00D0\u00BD\u00D1\u0082 - -ASSET_ALREADY_EXISTS = \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2 \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -ASSET_DOES_NOT_EXIST = \u00D0\u0090\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -ASSET_DOES_NOT_MATCH_AT = \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D0\u00BE\u00D0\u00B2\u00D0\u00BF\u00D0\u00B0\u00D0\u00B4\u00D0\u00B0\u00D0\u00B5\u00D1\u0082 \u00D1\u0081 \u00D0\u0090\u00D0\u00A2 - -AT_ALREADY_EXISTS = AT \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -AT_IS_FINISHED = AT \u00D0\u00B2 \u00D0\u00B7\u00D0\u00B0\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D1\u0088\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B8 - -AT_UNKNOWN = \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u0090\u00D0\u00A2 - -BANNED_FROM_GROUP = \u00D0\u00B8\u00D1\u0081\u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087\u00D0\u00B5\u00D0\u00BD \u00D0\u00B8\u00D0\u00B7 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -BAN_EXISTS = \u00D0\u0091\u00D0\u00B0\u00D0\u00BD - -BAN_UNKNOWN = \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B1\u00D0\u00B0\u00D0\u00BD - -BUYER_ALREADY_OWNER = \u00D0\u00BF\u00D0\u00BE\u00D0\u00BA\u00D1\u0083\u00D0\u00BF\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D0\u00BE\u00D0\u00B1\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D0\u00BD\u00D0\u00BD\u00D0\u00B8\u00D0\u00BA - -DUPLICATE_OPTION = \u00D0\u00B4\u00D1\u0083\u00D0\u00B1\u00D0\u00BB\u00D0\u00B8\u00D1\u0080\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D1\u0082\u00D1\u008C \u00D0\u00B2\u00D0\u00B0\u00D1\u0080\u00D0\u00B8\u00D0\u00B0\u00D0\u00BD\u00D1\u0082 - -GROUP_ALREADY_EXISTS = \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00B0 \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -GROUP_APPROVAL_DECIDED = \u00D0\u00B3\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00B0 \u00D0\u00BE\u00D0\u00B4\u00D0\u00BE\u00D0\u00B1\u00D1\u0080\u00D0\u00B5\u00D0\u00BD\u00D0\u00B0 - -GROUP_APPROVAL_NOT_REQUIRED = \u00D0\u00B3\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00BE\u00D0\u00B2\u00D0\u00BE\u00D0\u00B5 \u00D0\u00BE\u00D0\u00B4\u00D0\u00BE\u00D0\u00B1\u00D1\u0080\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0082\u00D1\u0080\u00D0\u00B5\u00D0\u00B1\u00D1\u0083\u00D0\u00B5\u00D1\u0082\u00D1\u0081\u00D1\u008F - -GROUP_DOES_NOT_EXIST = \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -GROUP_ID_MISMATCH = \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D0\u00BE\u00D0\u00BE\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D1\u0082\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D0\u00B5 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080\u00D0\u00B0 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -GROUP_OWNER_CANNOT_LEAVE = \u00D0\u00B2\u00D0\u00BB\u00D0\u00B0\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B5\u00D1\u0086 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BC\u00D0\u00BE\u00D0\u00B6\u00D0\u00B5\u00D1\u0082 \u00D1\u0083\u00D0\u00B9\u00D1\u0082\u00D0\u00B8 - -HAVE_EQUALS_WANT = \u00D0\u00B8\u00D0\u00BC\u00D0\u00BC\u00D0\u00B5\u00D1\u008E\u00D1\u0082\u00D1\u0081\u00D1\u008F \u00D1\u0080\u00D0\u00B0\u00D0\u00B2\u00D0\u00BD\u00D1\u008B\u00D0\u00B5 \u00D0\u00B6\u00D0\u00B5\u00D0\u00BB\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u008F - -INSUFFICIENT_FEE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00BF\u00D0\u00BB\u00D0\u00B0\u00D1\u0082\u00D0\u00B0 - -INVALID_ADDRESS = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B0\u00D0\u00B4\u00D1\u0080\u00D0\u00B5\u00D1\u0081 - -INVALID_AMOUNT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0083\u00D0\u00BC\u00D0\u00BC\u00D0\u00B0 - -INVALID_ASSET_OWNER = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B2\u00D0\u00BB\u00D0\u00B0\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B5\u00D1\u0086 \u00D0\u00B0\u00D0\u00BA\u00D1\u0082\u00D0\u00B8\u00D0\u00B2\u00D0\u00B0 - -INVALID_AT_TRANSACTION = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u0090\u00D0\u00A2 \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F - -INVALID_AT_TYPE_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00BE \u00D0\u00B4\u00D0\u00BB\u00D1\u008F \u00D1\u0082\u00D0\u00B8\u00D0\u00BF\u00D0\u00B0 \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D1\u008B AT - -INVALID_CREATION_BYTES = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B5 \u00D0\u00B1\u00D0\u00B0\u00D0\u00B9\u00D1\u0082\u00D1\u008B \u00D1\u0081\u00D0\u00BE\u00D0\u00B7\u00D0\u00B4\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u008F - -INVALID_DESCRIPTION_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D0\u00B0 \u00D0\u00BE\u00D0\u00BF\u00D0\u00B8\u00D1\u0081\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u008F - -INVALID_GROUP_APPROVAL_THRESHOLD = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D0\u00BE\u00D1\u0080\u00D0\u00BE\u00D0\u00B3 \u00D1\u0083\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D1\u008F \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -INVALID_GROUP_ID = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -INVALID_GROUP_OWNER = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083 \u00D0\u00B2\u00D0\u00BB\u00D0\u00B0\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B5\u00D1\u0086 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -INVALID_LIFETIME = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083 \u00D1\u0081\u00D1\u0080\u00D0\u00BE\u00D0\u00BA \u00D1\u0081\u00D0\u00BB\u00D1\u0083\u00D0\u00B6\u00D0\u00B1\u00D1\u008B - -INVALID_NAME_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D0\u00B0 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -INVALID_NAME_OWNER = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00BE\u00D0\u00B5 \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D0\u00B2\u00D0\u00BB\u00D0\u00B0\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D1\u0086\u00D0\u00B0 - -INVALID_OPTIONS_COUNT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D0\u00BE\u00D0\u00B5 \u00D0\u00BA\u00D0\u00BE\u00D0\u00BB\u00D0\u00B8\u00D1\u0087\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00BE \u00D0\u00BE\u00D0\u00BF\u00D1\u0086\u00D0\u00B8\u00D0\u00B9 - -INVALID_OPTION_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D1\u008F \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D0\u00B0 \u00D0\u00BE\u00D0\u00BF\u00D1\u0086\u00D0\u00B8\u00D0\u00B8 - -INVALID_ORDER_CREATOR = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B9 \u00D1\u0081\u00D0\u00BE\u00D0\u00B7\u00D0\u00B4\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7\u00D0\u00B0 - -INVALID_PAYMENTS_COUNT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D0\u00BE\u00D0\u00B4\u00D1\u0081\u00D1\u0087\u00D0\u00B5\u00D1\u0082 \u00D0\u00BF\u00D0\u00BB\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00B6\u00D0\u00B5\u00D0\u00B9 - -INVALID_PUBLIC_KEY = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 - -INVALID_QUANTITY = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00BE\u00D0\u00B5 \u00D0\u00BA\u00D0\u00BE\u00D0\u00BB\u00D0\u00B8\u00D1\u0087\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00BE - -INVALID_REFERENCE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0081\u00D1\u008B\u00D0\u00BB\u00D0\u00BA\u00D0\u00B0 - -INVALID_RETURN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D1\u008B\u00D0\u00B9 \u00D0\u00B2\u00D0\u00BE\u00D0\u00B7\u00D0\u00B2\u00D1\u0080\u00D0\u00B0\u00D1\u0082 - -INVALID_REWARD_SHARE_PERCENT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D1\u0086\u00D0\u00B5\u00D0\u00BD\u00D1\u0082 \u00D0\u00BD\u00D0\u00B0\u00D0\u00B3\u00D1\u0080\u00D0\u00B0\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D1\u008F - -INVALID_SELLER = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D0\u00B4\u00D0\u00B0\u00D0\u00B2\u00D0\u00B5\u00D1\u0086 - -INVALID_TAGS_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D0\u00B0 \u00D1\u0082\u00D1\u008D\u00D0\u00B3\u00D0\u00BE\u00D0\u00B2 - -INVALID_TX_GROUP_ID = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00B5\u00D0\u00B9\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D0\u00B8\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D0\u00B8\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D1\u0082\u00D0\u00B8\u00D1\u0084\u00D0\u00B8\u00D0\u00BA\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B \u00D0\u00BF\u00D0\u00B5\u00D1\u0080\u00D0\u00B5\u00D0\u00B4\u00D0\u00B0\u00D1\u0087\u00D0\u00B8 - -INVALID_VALUE_LENGTH = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D0\u00BF\u00D1\u0083\u00D1\u0081\u00D1\u0082\u00D0\u00B8\u00D0\u00BC\u00D0\u00BE\u00D0\u00B5 \u00D0\u00B7\u00D0\u00BD\u00D0\u00B0\u00D1\u0087\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 \u00D0\u00B4\u00D0\u00BB\u00D0\u00B8\u00D0\u00BD\u00D1\u008B - -JOIN_REQUEST_EXISTS = \u00D0\u00B7\u00D0\u00B0\u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D1\u0081 \u00D0\u00BD\u00D0\u00B0 \u00D0\u00BF\u00D1\u0080\u00D0\u00B8\u00D1\u0081\u00D0\u00BE\u00D0\u00B5\u00D0\u00B4\u00D0\u00B8\u00D0\u00BD\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -MAXIMUM_REWARD_SHARES = \u00D0\u00BC\u00D0\u00B0\u00D0\u00BA\u00D1\u0081\u00D0\u00B8\u00D0\u00BC\u00D0\u00B0\u00D0\u00BB\u00D1\u008C\u00D0\u00BD\u00D0\u00BE\u00D0\u00B5 \u00D0\u00B2\u00D0\u00BE\u00D0\u00B7\u00D0\u00BD\u00D0\u00B0\u00D0\u00B3\u00D1\u0080\u00D0\u00B0\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D0\u00B5 - -MISSING_CREATOR = \u00D0\u00BE\u00D1\u0082\u00D1\u0081\u00D1\u0083\u00D1\u0082\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D1\u008E\u00D1\u0089\u00D0\u00B8\u00D0\u00B9 \u00D1\u0081\u00D0\u00BE\u00D0\u00B7\u00D0\u00B4\u00D0\u00B0\u00D1\u0082\u00D0\u00B5\u00D0\u00BB\u00D1\u008C - -MULTIPLE_NAMES_FORBIDDEN = \u00D0\u00BD\u00D0\u00B5\u00D1\u0081\u00D0\u00BA\u00D0\u00BE\u00D0\u00BB\u00D1\u008C\u00D0\u00BA\u00D0\u00BE \u00D0\u00B8\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD \u00D0\u00B7\u00D0\u00B0\u00D0\u00BF\u00D1\u0080\u00D0\u00B5\u00D1\u0089\u00D0\u00B5\u00D0\u00BD\u00D0\u00BE - -NAME_ALREADY_FOR_SALE = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00B2 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D0\u00B4\u00D0\u00B0\u00D0\u00B6\u00D0\u00B5 - -NAME_ALREADY_REGISTERED = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00B7\u00D0\u00B0\u00D1\u0080\u00D0\u00B5\u00D0\u00B3\u00D0\u00B8\u00D1\u0081\u00D1\u0082\u00D1\u0080\u00D0\u00B8\u00D1\u0080\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D0\u00BD\u00D0\u00BE - -NAME_DOES_NOT_EXIST = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -NAME_NOT_FOR_SALE = \u00D0\u00B8\u00D0\u00BC\u00D1\u008F \u00D0\u00BD\u00D0\u00B5 \u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D0\u00B4\u00D0\u00B0\u00D0\u00B5\u00D1\u0082\u00D1\u0081\u00D1\u008F - -NAME_NOT_LOWER_CASE = \u00D0\u00B8\u00D0\u00BC\u00D0\u00BC\u00D1\u008F \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B4\u00D0\u00BE\u00D0\u00BB\u00D0\u00B6\u00D0\u00BD\u00D0\u00BE \u00D1\u0081\u00D0\u00BE\u00D0\u00B4\u00D0\u00B5\u00D1\u0080\u00D0\u00B6\u00D0\u00B0\u00D1\u0082\u00D1\u008C \u00D1\u0081\u00D1\u0082\u00D1\u0080\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D1\u008B\u00D0\u00B9 \u00D1\u0080\u00D0\u00B5\u00D0\u00B3\u00D0\u00B8\u00D1\u0081\u00D1\u0082\u00D1\u0080 - -NEGATIVE_AMOUNT = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0083\u00D0\u00BC\u00D0\u00BC\u00D0\u00B0 - -NEGATIVE_FEE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D0\u00BA\u00D0\u00BE\u00D0\u00BC\u00D0\u00B8\u00D1\u0081\u00D1\u0081\u00D0\u00B8\u00D1\u008F - -NEGATIVE_PRICE = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B4\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0087\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0081\u00D1\u0082\u00D0\u00BE\u00D0\u00B8\u00D0\u00BC\u00D0\u00BE\u00D1\u0081\u00D1\u0082\u00D1\u008C - -NOT_GROUP_ADMIN = \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B0\u00D0\u00B4\u00D0\u00BC\u00D0\u00B8\u00D0\u00BD\u00D0\u00B8\u00D1\u0081\u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D1\u0082\u00D0\u00BE\u00D1\u0080 \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -NOT_GROUP_MEMBER = \u00D0\u00BD\u00D0\u00B5 \u00D1\u0087\u00D0\u00BB\u00D0\u00B5\u00D0\u00BD \u00D0\u00B3\u00D1\u0080\u00D1\u0083\u00D0\u00BF\u00D0\u00BF\u00D1\u008B - -NOT_MINTING_ACCOUNT = \u00D1\u0081\u00D1\u0087\u00D0\u00B5\u00D1\u0082 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0087\u00D0\u00B5\u00D0\u00BA\u00D0\u00B0\u00D0\u00BD\u00D0\u00B8\u00D1\u0082 - -NOT_YET_RELEASED = \u00D0\u00B5\u00D1\u0089\u00D0\u00B5 \u00D0\u00BD\u00D0\u00B5 \u00D0\u00B2\u00D1\u008B\u00D0\u00BF\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D0\u00BD\u00D0\u00BE - -NO_BALANCE = \u00D0\u00BD\u00D0\u00B5\u00D1\u0082 \u00D0\u00B1\u00D0\u00B0\u00D0\u00BB\u00D0\u00B0\u00D0\u00BD\u00D1\u0081\u00D0\u00B0 - -NO_BLOCKCHAIN_LOCK = \u00D0\u00B1\u00D0\u00BB\u00D0\u00BE\u00D0\u00BA\u00D1\u0087\u00D0\u00B5\u00D0\u00B9\u00D0\u00BD \u00D1\u0083\u00D0\u00B7\u00D0\u00BB\u00D0\u00B0 \u00D0\u00B2 \u00D0\u00BD\u00D0\u00B0\u00D1\u0081\u00D1\u0082\u00D0\u00BE\u00D1\u008F\u00D1\u0089\u00D0\u00B5\u00D0\u00B5 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D1\u008F \u00D0\u00B7\u00D0\u00B0\u00D0\u00BD\u00D1\u008F\u00D1\u0082 - -NO_FLAG_PERMISSION = \u00D0\u00BD\u00D0\u00B5\u00D1\u0082 \u00D1\u0080\u00D0\u00B0\u00D0\u00B7\u00D1\u0080\u00D0\u00B5\u00D1\u0088\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8\u00D1\u008F \u00D0\u00BD\u00D0\u00B0 \u00D1\u0084\u00D0\u00BB\u00D0\u00B0\u00D0\u00B3 - -OK = OK - -ORDER_ALREADY_CLOSED = \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7 \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082 - -ORDER_DOES_NOT_EXIST = \u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D0\u00B0\u00D0\u00B7\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -POLL_ALREADY_EXISTS = \u00D0\u00BE\u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D1\u0081 \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -POLL_DOES_NOT_EXIST = \u00D0\u00BE\u00D0\u00BF\u00D1\u0080\u00D0\u00BE\u00D1\u0081\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -POLL_OPTION_DOES_NOT_EXIST = \u00D0\u00B2\u00D0\u00B0\u00D1\u0080\u00D0\u00B8\u00D0\u00B0\u00D0\u00BD\u00D1\u0082\u00D0\u00BE\u00D0\u00B2 \u00D0\u00BE\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D1\u0082\u00D0\u00B0 \u00D0\u00BD\u00D0\u00B5 \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -PUBLIC_KEY_UNKNOWN = \u00D0\u00BE\u00D1\u0082\u00D0\u00BA\u00D1\u0080\u00D1\u008B\u00D1\u0082\u00D1\u008B\u00D0\u00B9 \u00D0\u00BA\u00D0\u00BB\u00D1\u008E\u00D1\u0087 \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B5\u00D0\u00BD - -SELF_SHARE_EXISTS = \u00D0\u00BF\u00D0\u00BE\u00D0\u00B4\u00D0\u00B5\u00D0\u00BB\u00D0\u00B8\u00D1\u0082\u00D1\u008C\u00D1\u0081\u00D1\u008F \u00D0\u00B4\u00D0\u00BE\u00D0\u00BB\u00D0\u00B5\u00D0\u00B9 - -TIMESTAMP_TOO_NEW = \u00D0\u00BD\u00D0\u00BE\u00D0\u00B2\u00D0\u00B0\u00D1\u008F \u00D0\u00BC\u00D0\u00B5\u00D1\u0082\u00D0\u00BA\u00D0\u00B0 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8 - -TIMESTAMP_TOO_OLD = \u00D1\u0081\u00D1\u0082\u00D0\u00B0\u00D1\u0080\u00D0\u00B0\u00D1\u008F \u00D0\u00BC\u00D0\u00B5\u00D1\u0082\u00D0\u00BA\u00D0\u00B0 \u00D0\u00B2\u00D1\u0080\u00D0\u00B5\u00D0\u00BC\u00D0\u00B5\u00D0\u00BD\u00D0\u00B8 - -TRANSACTION_ALREADY_CONFIRMED = \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F \u00D1\u0083\u00D0\u00B6\u00D0\u00B5 \u00D0\u00BF\u00D0\u00BE\u00D0\u00B4\u00D1\u0082\u00D0\u00B2\u00D0\u00B5\u00D1\u0080\u00D0\u00B6\u00D0\u00B4\u00D0\u00B5\u00D0\u00BD\u00D0\u00B0 - -TRANSACTION_ALREADY_EXISTS = \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F \u00D1\u0081\u00D1\u0083\u00D1\u0089\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00B2\u00D1\u0083\u00D0\u00B5\u00D1\u0082 - -TRANSACTION_UNKNOWN = \u00D0\u00BD\u00D0\u00B5\u00D0\u00B8\u00D0\u00B7\u00D0\u00B2\u00D0\u00B5\u00D1\u0081\u00D1\u0082\u00D0\u00BD\u00D0\u00B0\u00D1\u008F \u00D1\u0082\u00D1\u0080\u00D0\u00B0\u00D0\u00BD\u00D0\u00B7\u00D0\u00B0\u00D0\u00BA\u00D1\u0086\u00D0\u00B8\u00D1\u008F + +ACCOUNT_ALREADY_EXISTS = аккаунт уже существует + +ACCOUNT_CANNOT_REWARD_SHARE = аккаунт не может делиться вознаграждением + +ALREADY_GROUP_ADMIN = уже администратор группы + +ALREADY_GROUP_MEMBER = уже член группы + +ALREADY_VOTED_FOR_THAT_OPTION = уже проголосовали за этот вариант + +ASSET_ALREADY_EXISTS = актив уже существует + +ASSET_DOES_NOT_EXIST = Актив не существует + +ASSET_DOES_NOT_MATCH_AT = актив не совпадает с АТ + +ASSET_NOT_SPENDABLE = актив не подлежит расходованию + +AT_ALREADY_EXISTS = AT уже существует + +AT_IS_FINISHED = AT в завершении + +AT_UNKNOWN = не известный АТ + +BANNED_FROM_GROUP = исключен из группы + +BAN_EXISTS = Бан + +BAN_UNKNOWN = не известный бан + +BUYER_ALREADY_OWNER = покупатель уже собственник + +CLOCK_NOT_SYNCED = часы не синхронизированы + +DUPLICATE_OPTION = дублировать вариант + +GROUP_ALREADY_EXISTS = группа уже существует + +GROUP_APPROVAL_DECIDED = гуппа одобрена + +GROUP_APPROVAL_NOT_REQUIRED = гупповое одобрение не требуется + +GROUP_DOES_NOT_EXIST = группа не существует + +GROUP_ID_MISMATCH = не соответствие идентификатора группы + +GROUP_OWNER_CANNOT_LEAVE = владелец группы не может уйти + +HAVE_EQUALS_WANT = иммеются равные желания + +INSUFFICIENT_FEE = недостаточная плата + +INVALID_ADDRESS = недействительный адрес + +INVALID_AMOUNT = недопустимая сумма + +INVALID_ASSET_OWNER = недействительный владелец актива + +INVALID_AT_TRANSACTION = недействительная АТ транзакция + +INVALID_AT_TYPE_LENGTH = недействительно для типа длины AT + +INVALID_CREATION_BYTES = недопустимые байты создания + +INVALID_DATA_LENGTH = недопустимая длина данных + +INVALID_DESCRIPTION_LENGTH = недопустимая длина описания + +INVALID_GROUP_APPROVAL_THRESHOLD = недопустимый порог утверждения группы + +INVALID_GROUP_ID = недопустимый идентификатор группы + +INVALID_GROUP_OWNER = недопу владелец группы + +INVALID_LIFETIME = недопу срок службы + +INVALID_NAME_LENGTH = недопустимая длина группы + +INVALID_NAME_OWNER = недопустимое имя владельца + +INVALID_OPTIONS_COUNT = неверное количество опций + +INVALID_OPTION_LENGTH = недопустимая длина опции + +INVALID_ORDER_CREATOR = недопустимый создатель заказа + +INVALID_PAYMENTS_COUNT = неверный подсчет платежей + +INVALID_PUBLIC_KEY = недействительный открытый ключ + +INVALID_QUANTITY = недопустимое количество + +INVALID_REFERENCE = неверная ссылка + +INVALID_RETURN = недопустимый возврат + +INVALID_REWARD_SHARE_PERCENT = недействительный процент награждения + +INVALID_SELLER = недействительный продавец + +INVALID_TAGS_LENGTH = недействительная длина тэгов + +INVALID_TX_GROUP_ID = недействительный идентификатор группы передачи + +INVALID_VALUE_LENGTH = недопустимое значение длины + +INVITE_UNKNOWN = приглашать неизветсных + +JOIN_REQUEST_EXISTS = запрос на присоединение существует + +MAXIMUM_REWARD_SHARES = максимальное вознаграждение + +MISSING_CREATOR = отсутствующий создатель + +MULTIPLE_NAMES_FORBIDDEN = несколько имен запрещено + +NAME_ALREADY_FOR_SALE = имя уже в продаже + +NAME_ALREADY_REGISTERED = имя уже зарегистрировано + +NAME_DOES_NOT_EXIST = имя не существует + +NAME_NOT_FOR_SALE = имя не продается + +NAME_NOT_LOWER_CASE = иммя не должно содержать строчный регистр + +NEGATIVE_AMOUNT = недостаточная сумма + +NEGATIVE_FEE = недостаточная комиссия + +NEGATIVE_PRICE = недостаточная стоимость + +NOT_GROUP_ADMIN = не администратор группы + +NOT_GROUP_MEMBER = не член группы + +NOT_MINTING_ACCOUNT = счет не чеканит + +NOT_YET_RELEASED = еще не выпущено + +NO_BALANCE = нет баланса + +NO_BLOCKCHAIN_LOCK = блокчейн узла в настоящее время занят + +NO_FLAG_PERMISSION = нет разрешения на флаг + +OK = OK + +ORDER_ALREADY_CLOSED = заказ закрыт + +ORDER_DOES_NOT_EXIST = заказа не существует + +POLL_ALREADY_EXISTS = опрос уже существует + +POLL_DOES_NOT_EXIST = опроса не существует + +POLL_OPTION_DOES_NOT_EXIST = вариантов ответа не существует + +PUBLIC_KEY_UNKNOWN = открытый ключ неизвестен + +SELF_SHARE_EXISTS = поделиться долей + +TIMESTAMP_TOO_NEW = новая метка времени + +TIMESTAMP_TOO_OLD = старая метка времени + +TOO_MANY_UNCONFIRMED = много не подтвержденных + +TRANSACTION_ALREADY_CONFIRMED = транзакция уже подтверждена + +TRANSACTION_ALREADY_EXISTS = транзакция существует + +TRANSACTION_UNKNOWN = неизвестная транзакция + +TX_GROUP_ID_MISMATCH = не соответствие идентификатора группы c хэш транзации From 62ae49b639f578d9d64bb2fe862c7129bd28e448 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 12 Nov 2020 12:18:26 +0000 Subject: [PATCH 55/55] ApiError_de.properties & SysTray_zh.properties also converted to UTF8 --- .../resources/i18n/ApiError_de.properties | 10 +++---- src/main/resources/i18n/SysTray_zh.properties | 28 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/main/resources/i18n/ApiError_de.properties b/src/main/resources/i18n/ApiError_de.properties index b2825e0d..490aac0d 100644 --- a/src/main/resources/i18n/ApiError_de.properties +++ b/src/main/resources/i18n/ApiError_de.properties @@ -1,13 +1,13 @@ -INVALID_ADDRESS = ung\u00FCltige adresse +INVALID_ADDRESS = ungültige adresse -INVALID_ASSET_ID = ung\u00FCltige asset ID +INVALID_ASSET_ID = ungültige asset ID -INVALID_DATA = ung\u00FCltige daten +INVALID_DATA = ungültige daten -INVALID_PUBLIC_KEY = ung\u00FCltiger public key +INVALID_PUBLIC_KEY = ungültiger public key -INVALID_SIGNATURE = ung\u00FCltige signatur +INVALID_SIGNATURE = ungültige signatur JSON = JSON nachricht konnte nicht geparsed werden diff --git a/src/main/resources/i18n/SysTray_zh.properties b/src/main/resources/i18n/SysTray_zh.properties index bb2e1426..0aaa2e33 100644 --- a/src/main/resources/i18n/SysTray_zh.properties +++ b/src/main/resources/i18n/SysTray_zh.properties @@ -1,31 +1,31 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu -BLOCK_HEIGHT = \u5757\u9AD8\u5EA6 +BLOCK_HEIGHT = 块高度 -CHECK_TIME_ACCURACY = \u68C0\u67E5\u65F6\u95F4\u51C6\u786E\u6027 +CHECK_TIME_ACCURACY = 检查时间准确性 -CONNECTION = \u4E2A\u8FDE\u63A5 +CONNECTION = 个连接 -CONNECTIONS = \u4E2A\u8FDE\u63A5 +CONNECTIONS = 个连接 -EXIT = \u9000\u51FA\u8F6F\u4EF6 +EXIT = 退出软件 -MINTING_DISABLED = \u6CA1\u6709\u94F8\u5E01 +MINTING_DISABLED = 没有铸币 -MINTING_ENABLED = \u2714 \u94F8\u5E01 +MINTING_ENABLED = ✔ 铸币 # Nagging about lack of NTP time sync -NTP_NAG_CAPTION = \u7535\u8111\u7684\u65F6\u949F\u4E0D\u51C6\u786E\uFF01 +NTP_NAG_CAPTION = 电脑的时钟不准确! -NTP_NAG_TEXT_UNIX = \u5B89\u88C5NTP\u670D\u52A1\u4EE5\u83B7\u5F97\u51C6\u786E\u7684\u65F6\u949F\u3002 +NTP_NAG_TEXT_UNIX = 安装NTP服务以获得准确的时钟。 -NTP_NAG_TEXT_WINDOWS = \u4ECE\u83DC\u5355\u4E2D\u9009\u62E9\u201C\u540C\u6B65\u65F6\u949F\u201D\u8FDB\u884C\u4FEE\u590D\u3002 +NTP_NAG_TEXT_WINDOWS = 从菜单中选择“同步时钟”进行修复。 -OPEN_UI = \u5F00\u542F\u754C\u9762 +OPEN_UI = 开启界面 -SYNCHRONIZE_CLOCK = \u540C\u6B65\u65F6\u949F +SYNCHRONIZE_CLOCK = 同步时钟 -SYNCHRONIZING_BLOCKCHAIN = \u540C\u6B65\u533A\u5757\u94FE +SYNCHRONIZING_BLOCKCHAIN = 同步区块链 -SYNCHRONIZING_CLOCK = \u540C\u6B65\u7740\u65F6\u949F +SYNCHRONIZING_CLOCK = 同步着时钟