Skip to content

Commit

Permalink
Add system file fallback to Web3 (#8418)
Browse files Browse the repository at this point in the history
* Add file data fallback

Signed-off-by: Edwin Greene <[email protected]>
  • Loading branch information
edwin-greene authored Jun 5, 2024
1 parent 40d50a7 commit 3615383
Show file tree
Hide file tree
Showing 5 changed files with 431 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,22 @@
import static com.hedera.mirror.web3.evm.config.EvmConfiguration.CACHE_NAME_EXCHANGE_RATE;
import static com.hedera.mirror.web3.evm.config.EvmConfiguration.CACHE_NAME_FEE_SCHEDULE;

import com.google.common.primitives.Bytes;
import com.google.protobuf.InvalidProtocolBufferException;
import com.hedera.mirror.common.domain.entity.EntityId;
import com.hedera.mirror.common.domain.file.FileData;
import com.hedera.mirror.web3.repository.FileDataRepository;
import com.hederahashgraph.api.proto.java.CurrentAndNextFeeSchedule;
import com.hederahashgraph.api.proto.java.ExchangeRateSet;
import jakarta.inject.Named;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import lombok.CustomLog;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.retry.support.RetryTemplate;

/**
* Rates and fees loader, currently working only with current timestamp.
Expand All @@ -41,6 +47,11 @@
public class RatesAndFeesLoader {
private static final EntityId EXCHANGE_RATE_ENTITY_ID = EntityId.of(0L, 0L, 112L);
private static final EntityId FEE_SCHEDULE_ENTITY_ID = EntityId.of(0L, 0L, 111L);
private final RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(10)
.retryOn(InvalidProtocolBufferException.class)
.build();

private final FileDataRepository fileDataRepository;

/**
Expand All @@ -51,11 +62,11 @@ public class RatesAndFeesLoader {
*/
@Cacheable(cacheNames = CACHE_NAME_EXCHANGE_RATE, key = "'now'", unless = "#result == null")
public ExchangeRateSet loadExchangeRates(final long nanoSeconds) {
final var ratesFile = fileDataRepository.getFileAtTimestamp(EXCHANGE_RATE_ENTITY_ID.getId(), nanoSeconds);
try {
return ExchangeRateSet.parseFrom(ratesFile);
return getFileData(
EXCHANGE_RATE_ENTITY_ID.getId(), new AtomicLong(nanoSeconds), ExchangeRateSet::parseFrom);
} catch (InvalidProtocolBufferException e) {
log.warn("Corrupt rate file at {}, may require remediation!", EXCHANGE_RATE_ENTITY_ID, e);
log.warn("Corrupt rate file at {}, may require remediation!", EXCHANGE_RATE_ENTITY_ID);
throw new IllegalStateException(String.format("Rates %s are corrupt!", EXCHANGE_RATE_ENTITY_ID));
}
}
Expand All @@ -68,13 +79,49 @@ public ExchangeRateSet loadExchangeRates(final long nanoSeconds) {
*/
@Cacheable(cacheNames = CACHE_NAME_FEE_SCHEDULE, key = "'now'", unless = "#result == null")
public CurrentAndNextFeeSchedule loadFeeSchedules(final long nanoSeconds) {
final var feeScheduleFile = fileDataRepository.getFileAtTimestamp(FEE_SCHEDULE_ENTITY_ID.getId(), nanoSeconds);

try {
return CurrentAndNextFeeSchedule.parseFrom(feeScheduleFile);
return getFileData(
FEE_SCHEDULE_ENTITY_ID.getId(), new AtomicLong(nanoSeconds), CurrentAndNextFeeSchedule::parseFrom);
} catch (InvalidProtocolBufferException e) {
log.warn("Corrupt fee schedules file at {}, may require remediation!", FEE_SCHEDULE_ENTITY_ID, e);
throw new IllegalStateException(String.format("Fee schedule %s is corrupt!", FEE_SCHEDULE_ENTITY_ID));
}
}

@SuppressWarnings("java:S1130")
private <T> T getFileData(long fileId, final AtomicLong nanoSeconds, FileDataParser<T> parser)
throws InvalidProtocolBufferException {
return retryTemplate.execute(context -> {
long nanos = nanoSeconds.get();
var fileDataList = fileDataRepository.getFileAtTimestamp(fileId, nanos);
var fileDataBytes = getBytesFromFileData(fileDataList);
try {
return parser.parse(fileDataBytes);
} catch (InvalidProtocolBufferException e) {
log.warn(
"Failed to load file data for fileId {} at {}, failing back to previous file. Retry attempt {}. Exception: ",
fileId,
nanos,
context.getRetryCount() + 1,
e);

// Decrement to a prior file's timestamp. The retryTemplate will use this as the next nanoSeconds
// parameter value
nanoSeconds.set(fileDataList.getFirst().getConsensusTimestamp() - 1);
throw e;
}
});
}

private byte[] getBytesFromFileData(List<FileData> files) {
List<byte[]> fileDataBytesList = new ArrayList<>();
for (int i = 0; i < files.size(); i++) {
fileDataBytesList.add(files.get(i).getFileData());
}
return Bytes.concat(fileDataBytesList.toArray(new byte[0][]));
}

private interface FileDataParser<T> {
T parse(byte[] bytes) throws InvalidProtocolBufferException;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.hedera.mirror.web3.repository;

import com.hedera.mirror.common.domain.file.FileData;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
Expand All @@ -26,17 +27,21 @@ public interface FileDataRepository extends CrudRepository<FileData, Long> {
@Query(
value =
"""
with latest_create as (
select max(file_data.consensus_timestamp) as consensus_timestamp
from file_data
where file_data.entity_id = ?1 and file_data.transaction_type in (17, 19)
)
select
string_agg(file_data.file_data, '' order by file_data.consensus_timestamp) as file_data
from file_data
join latest_create l on file_data.consensus_timestamp >= l.consensus_timestamp
where file_data.entity_id = ?1 and file_data.transaction_type in (16, 17, 19)
and ?2 >= l.consensus_timestamp""",
select * from file_data
where entity_id = ?1
and consensus_timestamp >= (
select consensus_timestamp
from file_data
where entity_id = ?1
and consensus_timestamp <= ?2
and (transaction_type = 17
or (transaction_type = 19
and
length(file_data) <> 0))
order by consensus_timestamp desc
limit 1
) and consensus_timestamp <= ?2
order by consensus_timestamp""",
nativeQuery = true)
byte[] getFileAtTimestamp(long fileId, long timestamp);
List<FileData> getFileAtTimestamp(long fileId, long timestamp);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/*
* Copyright (C) 2023-2024 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.hedera.mirror.web3.evm.pricing;

import static com.hedera.mirror.common.domain.transaction.TransactionType.FILEAPPEND;
import static com.hedera.mirror.common.domain.transaction.TransactionType.FILECREATE;
import static com.hedera.mirror.common.domain.transaction.TransactionType.FILEUPDATE;
import static com.hederahashgraph.api.proto.java.HederaFunctionality.ContractCall;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

import com.hedera.mirror.common.domain.entity.EntityId;
import com.hedera.mirror.web3.Web3IntegrationTest;
import com.hederahashgraph.api.proto.java.CurrentAndNextFeeSchedule;
import com.hederahashgraph.api.proto.java.ExchangeRate;
import com.hederahashgraph.api.proto.java.ExchangeRateSet;
import com.hederahashgraph.api.proto.java.FeeData;
import com.hederahashgraph.api.proto.java.FeeSchedule;
import com.hederahashgraph.api.proto.java.TimestampSeconds;
import com.hederahashgraph.api.proto.java.TransactionFeeSchedule;
import jakarta.annotation.Resource;
import java.util.Arrays;
import lombok.RequiredArgsConstructor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

@RequiredArgsConstructor
class RatesAndFeesLoaderIntegrationTest extends Web3IntegrationTest {

@Resource
private RatesAndFeesLoader subject;

private static final ExchangeRateSet exchangeRatesSet = ExchangeRateSet.newBuilder()
.setCurrentRate(ExchangeRate.newBuilder()
.setCentEquiv(1)
.setHbarEquiv(12)
.setExpirationTime(TimestampSeconds.newBuilder().setSeconds(200L))
.build())
.setNextRate(ExchangeRate.newBuilder()
.setCentEquiv(2)
.setHbarEquiv(31)
.setExpirationTime(TimestampSeconds.newBuilder().setSeconds(2_234_567_890L))
.build())
.build();

private static final ExchangeRateSet exchangeRatesSet2 = ExchangeRateSet.newBuilder()
.setCurrentRate(ExchangeRate.newBuilder()
.setCentEquiv(3)
.setHbarEquiv(14)
.setExpirationTime(TimestampSeconds.newBuilder().setSeconds(300))
.build())
.setNextRate(ExchangeRate.newBuilder()
.setCentEquiv(4)
.setHbarEquiv(33)
.setExpirationTime(TimestampSeconds.newBuilder().setSeconds(2_234_567_893L))
.build())
.build();

private static final CurrentAndNextFeeSchedule feeSchedules = CurrentAndNextFeeSchedule.newBuilder()
.setCurrentFeeSchedule(FeeSchedule.newBuilder()
.setExpiryTime(TimestampSeconds.newBuilder().setSeconds(200L))
.addTransactionFeeSchedule(TransactionFeeSchedule.newBuilder()
.setHederaFunctionality(ContractCall)
.addFees(FeeData.newBuilder().build())))
.setNextFeeSchedule(FeeSchedule.newBuilder()
.setExpiryTime(TimestampSeconds.newBuilder().setSeconds(2_234_567_890L))
.addTransactionFeeSchedule(TransactionFeeSchedule.newBuilder()
.setHederaFunctionality(ContractCall)
.addFees(FeeData.newBuilder().build())))
.build();

private static final CurrentAndNextFeeSchedule feeSchedules2 = CurrentAndNextFeeSchedule.newBuilder()
.setCurrentFeeSchedule(FeeSchedule.newBuilder()
.setExpiryTime(TimestampSeconds.newBuilder().setSeconds(300L))
.addTransactionFeeSchedule(TransactionFeeSchedule.newBuilder()
.setHederaFunctionality(ContractCall)
.addFees(FeeData.newBuilder().build())))
.setNextFeeSchedule(FeeSchedule.newBuilder()
.setExpiryTime(TimestampSeconds.newBuilder().setSeconds(2_234_567_890L))
.addTransactionFeeSchedule(TransactionFeeSchedule.newBuilder()
.setHederaFunctionality(ContractCall)
.addFees(FeeData.newBuilder().build())))
.build();

private static final EntityId FEE_SCHEDULE_ENTITY_ID = EntityId.of(0L, 0L, 111L);
private static final EntityId EXCHANGE_RATE_ENTITY_ID = EntityId.of(0L, 0L, 112L);

@ParameterizedTest
@ValueSource(booleans = {true, false})
void getFileForExchangeRateFallback(boolean corrupt) {
var exchangeSetBytes = exchangeRatesSet.toByteArray();
var exchangeSetPart1 = Arrays.copyOfRange(exchangeSetBytes, 0, 10);
var exchangeSetPart2 = Arrays.copyOfRange(exchangeSetBytes, 10, 20);
var exchangeSetPart3 = Arrays.copyOfRange(exchangeSetBytes, 20, exchangeSetBytes.length);
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILECREATE.getProtoId())
.fileData(exchangeSetPart1)
.entityId(EXCHANGE_RATE_ENTITY_ID)
.consensusTimestamp(200L))
.persist();
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEAPPEND.getProtoId())
.fileData(exchangeSetPart2)
.entityId(EXCHANGE_RATE_ENTITY_ID)
.consensusTimestamp(205L))
.persist();
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEAPPEND.getProtoId())
.fileData(exchangeSetPart3)
.entityId(EXCHANGE_RATE_ENTITY_ID)
.consensusTimestamp(210L))
.persist();

var exchangeSet2Bytes = exchangeRatesSet2.toByteArray();
var exchangeSet2Part1 = Arrays.copyOfRange(exchangeSet2Bytes, 0, 10);
var exchangeSet2Part2 = Arrays.copyOfRange(exchangeSet2Bytes, 10, 20);
var exchangeSet2Part3 = Arrays.copyOfRange(exchangeSet2Bytes, 20, exchangeSet2Bytes.length);
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEUPDATE.getProtoId())
.fileData(exchangeSet2Part1)
.entityId(EXCHANGE_RATE_ENTITY_ID)
.consensusTimestamp(300L))
.persist();
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEAPPEND.getProtoId())
.fileData(exchangeSet2Part2)
.entityId(EXCHANGE_RATE_ENTITY_ID)
.consensusTimestamp(305L))
.persist();

var fileData = corrupt ? "corrupt".getBytes() : exchangeSet2Part3;
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEAPPEND.getProtoId())
.fileData(fileData)
.entityId(EXCHANGE_RATE_ENTITY_ID)
.consensusTimestamp(310L))
.persist();

var expected = corrupt ? exchangeRatesSet : exchangeRatesSet2;
var actual = subject.loadExchangeRates(350L);
assertThat(actual).isEqualTo(expected);
}

@ParameterizedTest
@ValueSource(booleans = {true, false})
void getFileForFeeScheduleFallback(boolean corrupt) {
var feeSchedulesBytes = feeSchedules.toByteArray();
var feeSchedulesPart1 = Arrays.copyOfRange(feeSchedulesBytes, 0, 10);
var feeSchedulesPart2 = Arrays.copyOfRange(feeSchedulesBytes, 10, 20);
var feeSchedulesPart3 = Arrays.copyOfRange(feeSchedulesBytes, 20, feeSchedulesBytes.length);
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEUPDATE.getProtoId())
.fileData(feeSchedulesPart1)
.entityId(FEE_SCHEDULE_ENTITY_ID)
.consensusTimestamp(200L))
.persist();
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEAPPEND.getProtoId())
.fileData(feeSchedulesPart2)
.entityId(FEE_SCHEDULE_ENTITY_ID)
.consensusTimestamp(205L))
.persist();
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEAPPEND.getProtoId())
.fileData(feeSchedulesPart3)
.entityId(FEE_SCHEDULE_ENTITY_ID)
.consensusTimestamp(210L))
.persist();

var feeSchedules2Bytes = feeSchedules2.toByteArray();
var feeSchedules2Part1 = Arrays.copyOfRange(feeSchedules2Bytes, 0, 10);
var feeSchedules2Part2 = Arrays.copyOfRange(feeSchedules2Bytes, 10, 20);
var feeSchedules2Part3 = Arrays.copyOfRange(feeSchedules2Bytes, 20, feeSchedules2Bytes.length);
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEUPDATE.getProtoId())
.fileData(feeSchedules2Part1)
.entityId(FEE_SCHEDULE_ENTITY_ID)
.consensusTimestamp(300L))
.persist();
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEAPPEND.getProtoId())
.fileData(feeSchedules2Part2)
.entityId(FEE_SCHEDULE_ENTITY_ID)
.consensusTimestamp(305L))
.persist();

var fileData = corrupt ? "corrupt".getBytes() : feeSchedules2Part3;
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILEAPPEND.getProtoId())
.fileData(fileData)
.entityId(FEE_SCHEDULE_ENTITY_ID)
.consensusTimestamp(310L))
.persist();

var expected = corrupt ? feeSchedules : feeSchedules2;
var actual = subject.loadFeeSchedules(350L);
assertThat(actual).isEqualTo(expected);
}

@Test
void fallbackToException() {
for (long i = 1; i <= 11; i++) {
long timestamp = i;
domainBuilder
.fileData()
.customize(f -> f.transactionType(FILECREATE.getProtoId())
.fileData("corrupt".getBytes())
.entityId(FEE_SCHEDULE_ENTITY_ID)
.consensusTimestamp(timestamp))
.persist();
}

assertThrows(IllegalStateException.class, () -> subject.loadFeeSchedules(12L));
}
}
Loading

0 comments on commit 3615383

Please sign in to comment.