diff --git a/common/src/main/java/org/tron/common/cron/CronExpression.java b/common/src/main/java/org/tron/common/cron/CronExpression.java index 0445ec2ce5b..7a63955db22 100644 --- a/common/src/main/java/org/tron/common/cron/CronExpression.java +++ b/common/src/main/java/org/tron/common/cron/CronExpression.java @@ -20,8 +20,11 @@ import java.io.Serializable; import java.text.ParseException; +import java.time.DayOfWeek; +import java.time.LocalDate; import java.util.Calendar; import java.util.Date; +import java.util.GregorianCalendar; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -205,7 +208,7 @@ */ public final class CronExpression implements Serializable { - public static final int MAX_YEAR = Calendar.getInstance().get(Calendar.YEAR) + 100; + public static final int MAX_YEAR = new GregorianCalendar().get(Calendar.YEAR) + 100; private static final int SECOND = 0; private static final int MINUTE = 1; private static final int HOUR = 2; @@ -573,6 +576,9 @@ private int storeExpressionVals(int pos, String s, int type) addToSet(ALL_SPEC_INT, -1, incr, type); return i; } else if (c == 'L') { + if (type < DAY_OF_MONTH) { + throw new ParseException("'L' not expected in seconds, minutes or hours fields.", i); + } i++; if (type == DAY_OF_WEEK) { addToSet(7, 7, 0, type); @@ -631,9 +637,9 @@ private int storeExpressionVals(int pos, String s, int type) private void checkIncrementRange(int incr, int type, int idxPos) throws ParseException { if (incr > 59 && (type == SECOND || type == MINUTE)) { - throw new ParseException("Increment > 60 : " + incr, idxPos); + throw new ParseException("Increment >= 60 : " + incr, idxPos); } else if (incr > 23 && (type == HOUR)) { - throw new ParseException("Increment > 24 : " + incr, idxPos); + throw new ParseException("Increment >= 24 : " + incr, idxPos); } else if (incr > 31 && (type == DAY_OF_MONTH)) { throw new ParseException("Increment > 31 : " + incr, idxPos); } else if (incr > 7 && (type == DAY_OF_WEEK)) { @@ -1092,45 +1098,8 @@ public Date getTimeAfter(Date afterTime) { if (dayOfMSpec && !dayOfWSpec) { // get day by day of month rule final Optional smallestDay = findSmallestDay(day, mon, cl.get(Calendar.YEAR), daysOfMonth); - Optional smallestDayForWeekday = findSmallestDay(day, mon, cl.get(Calendar.YEAR), - nearestWeekdays); t = day; - day = -1; - if (smallestDayForWeekday.isPresent()) { - day = smallestDayForWeekday.get(); - - java.util.Calendar tcal = java.util.Calendar.getInstance(getTimeZone()); - tcal.set(Calendar.SECOND, 0); - tcal.set(Calendar.MINUTE, 0); - tcal.set(Calendar.HOUR_OF_DAY, 0); - tcal.set(Calendar.DAY_OF_MONTH, day); - tcal.set(Calendar.MONTH, mon - 1); - tcal.set(Calendar.YEAR, cl.get(Calendar.YEAR)); - - int ldom = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); - int dow = tcal.get(Calendar.DAY_OF_WEEK); - - if (dow == Calendar.SATURDAY && day == 1) { - day += 2; - } else if (dow == Calendar.SATURDAY) { - day -= 1; - } else if (dow == Calendar.SUNDAY && day == ldom) { - day -= 2; - } else if (dow == Calendar.SUNDAY) { - day += 1; - } - - - tcal.set(Calendar.SECOND, sec); - tcal.set(Calendar.MINUTE, min); - tcal.set(Calendar.HOUR_OF_DAY, hr); - tcal.set(Calendar.DAY_OF_MONTH, day); - tcal.set(Calendar.MONTH, mon - 1); - Date nTime = tcal.getTime(); - if (nTime.before(afterTime)) { - day = -1; - } - } + day = findNearestWeekday(t, mon, cl.get(Calendar.YEAR)); if (smallestDay.isPresent()) { if (day == -1 || smallestDay.get() < day) { day = smallestDay.get(); @@ -1395,17 +1364,41 @@ private Optional findSmallestDay(int day, int mon, int year, TreeSet d - LAST_DAY_OFFSET_START + 1) + final int smallestDay = Optional.ofNullable(set.ceiling(LAST_DAY_OFFSET_END - (lastDay - day))) + .map(d -> lastDay - (LAST_DAY_OFFSET_END - d)) .orElse(Integer.MAX_VALUE); // For "1", "2", etc. SortedSet st = set.subSet(day, LAST_DAY_OFFSET_START); // make sure we don't over-run a short month, such as february if (!st.isEmpty() && st.first() < smallestDay && st.first() <= lastDay) { - smallestDay = st.first(); + return Optional.of(st.first()); } return smallestDay == Integer.MAX_VALUE ? Optional.empty() : Optional.of(smallestDay); } + + private int findNearestWeekday(int day, int mon, int year) { + Optional nominal = findSmallestDay(day > 3 ? day - 2 : 1, mon, year, + nearestWeekdays); + while (nominal.isPresent()) { + int weekday = getNearestWeekday(nominal.get(), mon, year); + if (weekday >= day) { + return weekday; + } + nominal = findSmallestDay(nominal.get() + 1, mon, year, nearestWeekdays); + } + return -1; + } + + private int getNearestWeekday(int day, int mon, int year) { + DayOfWeek dayOfWeek = LocalDate.of(year, mon, day).getDayOfWeek(); + if (dayOfWeek == DayOfWeek.SATURDAY) { + return day == 1 ? 3 : day - 1; + } + if (dayOfWeek == DayOfWeek.SUNDAY) { + return day == getLastDayOfMonth(mon, year) ? day - 2 : day + 1; + } + return day; + } } diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 3b45be3cdb2..fe7c28c8934 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -149,8 +149,12 @@ node.discovery = { } # Custom stop condition +# BlockTime stops the node only at a block whose timestamp equals a cron time exactly (UTC). +# Mainnet produces a block every 3 s, so the seconds field must be a multiple of 3, and +# HH:00:03 / HH:00:06 UTC (HH = 00, 06, 12, 18) normally have no block (maintenance skip). +# If no block lands on that second (e.g. a missed slot), the node keeps running. # node.shutdown = { -# BlockTime = "54 59 08 * * ?" # if block header time in persistent db matched +# BlockTime = "54 59 08 * * ?" # cron evaluated in UTC, if block header time in persistent db matched # BlockHeight = 33350800 # if block header height in persistent db matched # BlockCount = 12 # block sync count after node start # } diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index a594576c96d..0dd2b8f3b88 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -18,6 +18,7 @@ import java.net.InetAddress; import java.net.InetSocketAddress; import java.text.ParseException; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -28,6 +29,7 @@ import java.util.Map; import java.util.Properties; import java.util.Set; +import java.util.TimeZone; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @@ -676,7 +678,9 @@ private static void applyNodeConfig(NodeConfig nc) { String shutdownBlockTime = nc.getShutdownBlockTime(); if (!shutdownBlockTime.isEmpty()) { try { - PARAMETER.shutdownBlockTime = new CronExpression(shutdownBlockTime); + CronExpression blockTime = new CronExpression(shutdownBlockTime); + blockTime.setTimeZone(TimeZone.getTimeZone(ZoneOffset.UTC)); + PARAMETER.shutdownBlockTime = blockTime; } catch (ParseException e) { throw new TronError(e, TronError.ErrCode.AUTO_STOP_PARAMS); } diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 9e5d2db7547..ba633e8c80f 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -14,6 +14,8 @@ import com.google.common.primitives.Longs; import com.google.protobuf.ByteString; import io.prometheus.client.Histogram; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -24,7 +26,9 @@ import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -186,6 +190,9 @@ public class Manager { private static final int TX_ID_CACHE_SIZE = 100_000; private static final int SLEEP_FOR_WAIT_LOCK = 10; private static final int NO_BLOCK_WAITING_LOCK = 0; + private static final int AUTO_STOP_PREVIEW_COUNT = 3; + private static final DateTimeFormatter AUTO_STOP_TIME_FORMAT = DateTimeFormatter + .ofPattern("EEE yyyy-MM-dd HH:mm:ss 'UTC'", Locale.US).withZone(ZoneOffset.UTC); private final int shieldedTransInPendingMaxCounts = Args.getInstance().getShieldedTransInPendingMaxCounts(); @Getter @@ -559,8 +566,8 @@ public void init() { // init auto-stop try { initAutoStop(); - } catch (IllegalArgumentException e) { - logger.error("Auto-stop params error: {}", e.getMessage()); + } catch (IllegalArgumentException | NoSuchElementException e) { + logger.error("Auto-stop params error: {}", e.toString()); throw new TronError(e, TronError.ErrCode.AUTO_STOP_PARAMS); } @@ -708,9 +715,16 @@ private void initAutoStop() { String.format("shutDownBlockCount %d is less than 1", exitCount)); } - if (blockTime != null && blockTime.getNextValidTimeAfter(new Date(headTime)) == null) { - throw new IllegalArgumentException( - String.format("shutDownBlockTime %s is illegal", blockTime)); + if (blockTime != null) { + List matches = nextCronMatches(blockTime, headTime); + if (matches.isEmpty()) { + throw new IllegalArgumentException( + String.format("shutDownBlockTime %s is illegal", blockTime)); + } + logger.info("Auto-stop: shutDownBlockTime {}, head block time {}, next cron matches {}. " + + "These are candidates: the node stops only if a block timestamp exactly matches " + + "one, and that is not guaranteed.", + blockTime, formatAutoStopTime(new Date(headTime)), matches); } if (exitHeight > 0 && exitCount > 0) { @@ -744,6 +758,23 @@ private void initAutoStop() { latestSolidityNumShutDown = CommonParameter.getInstance().getShutdownBlockHeight(); } + private List nextCronMatches(CronExpression cron, long headTime) { + List matches = new ArrayList<>(AUTO_STOP_PREVIEW_COUNT); + Date next = new Date(headTime); + for (int i = 0; i < AUTO_STOP_PREVIEW_COUNT; i++) { + next = cron.getNextValidTimeAfter(next); + if (next == null) { + break; + } + matches.add(formatAutoStopTime(next)); + } + return matches; + } + + private String formatAutoStopTime(Date time) { + return AUTO_STOP_TIME_FORMAT.format(time.toInstant()); + } + public AccountStore getAccountStore() { return chainBaseManager.getAccountStore(); } @@ -1045,11 +1076,13 @@ private void applyBlock(BlockCapsule block, List txs) updateFork(block); if (System.currentTimeMillis() - block.getTimeStamp() >= 60_000) { revokingStore.setMaxFlushCount(maxFlushCount); - if (Args.getInstance().getShutdownBlockTime() != null - && Args.getInstance().getShutdownBlockTime().getNextValidTimeAfter( - new Date(block.getTimeStamp() - maxFlushCount * 1000 * 3L)) - .compareTo(new Date(block.getTimeStamp())) <= 0) { - revokingStore.setMaxFlushCount(SnapshotManager.DEFAULT_MIN_FLUSH_COUNT); + CronExpression shutdownBlockTime = Args.getInstance().getShutdownBlockTime(); + if (shutdownBlockTime != null) { + Date nextStopTime = shutdownBlockTime.getNextValidTimeAfter( + new Date(block.getTimeStamp() - maxFlushCount * 1000 * 3L)); + if (nextStopTime != null && nextStopTime.compareTo(new Date(block.getTimeStamp())) <= 0) { + revokingStore.setMaxFlushCount(SnapshotManager.DEFAULT_MIN_FLUSH_COUNT); + } } if (latestSolidityNumShutDown > 0 && latestSolidityNumShutDown - block.getNum() <= maxFlushCount) { diff --git a/framework/src/main/java/org/tron/core/net/TronNetDelegate.java b/framework/src/main/java/org/tron/core/net/TronNetDelegate.java index 2948862b181..8744cf9840f 100644 --- a/framework/src/main/java/org/tron/core/net/TronNetDelegate.java +++ b/framework/src/main/java/org/tron/core/net/TronNetDelegate.java @@ -251,23 +251,23 @@ public void pushVerifiedBlock(BlockCapsule block) throws P2pException { } public void processBlock(BlockCapsule block, boolean isSync) throws P2pException { - if (!hitDown && dbManager.getLatestSolidityNumShutDown() > 0 - && dbManager.getLatestSolidityNumShutDown() == dbManager.getDynamicPropertiesStore() - .getLatestBlockHeaderNumberFromDB()) { - - logger.info("Begin shutdown, currentBlockNum:{}, DbBlockNum:{}, solidifiedBlockNum:{}", - dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumber(), - dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumberFromDB(), - dbManager.getDynamicPropertiesStore().getLatestSolidifiedBlockNum()); - hitDown = true; - LockSupport.unpark(hitThread); - return; - } - if (hitDown) { - return; - } BlockId blockId = block.getBlockId(); synchronized (blockLock) { + if (!hitDown && dbManager.getLatestSolidityNumShutDown() > 0 + && dbManager.getLatestSolidityNumShutDown() == dbManager.getDynamicPropertiesStore() + .getLatestBlockHeaderNumberFromDB()) { + + logger.info("Begin shutdown, currentBlockNum:{}, DbBlockNum:{}, solidifiedBlockNum:{}", + dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumber(), + dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumberFromDB(), + dbManager.getDynamicPropertiesStore().getLatestSolidifiedBlockNum()); + hitDown = true; + LockSupport.unpark(hitThread); + return; + } + if (hitDown) { + return; + } try { if (freshBlockId.getIfPresent(blockId) == null) { if (block.getNum() <= getHeadBlockId().getNum()) { diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 24aade2c71d..e764f7e32b9 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -50,8 +50,12 @@ node.discovery = { } # Custom stop condition +# BlockTime stops the node only at a block whose timestamp equals a cron time exactly (UTC). +# Mainnet produces a block every 3 s, so the seconds field must be a multiple of 3, and +# HH:00:03 / HH:00:06 UTC (HH = 00, 06, 12, 18) normally have no block (maintenance skip). +# If no block lands on that second (e.g. a missed slot), the node keeps running. # node.shutdown = { -# BlockTime = "54 59 08 * * ?" # if block header time in persistent db matched. +# BlockTime = "54 59 08 * * ?" # cron evaluated in UTC, if block header time in persistent db matched. # BlockHeight = 33350800 # if block header height in persistent db matched. # BlockCount = 12 # block sync count after node start. # } diff --git a/framework/src/test/java/org/tron/common/cron/CronExpressionLocaleTest.java b/framework/src/test/java/org/tron/common/cron/CronExpressionLocaleTest.java new file mode 100644 index 00000000000..094955ac584 --- /dev/null +++ b/framework/src/test/java/org/tron/common/cron/CronExpressionLocaleTest.java @@ -0,0 +1,69 @@ +package org.tron.common.cron; + +import static org.junit.Assert.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import org.junit.Test; + +public class CronExpressionLocaleTest { + + public static void main(String[] args) throws Exception { + CronExpression cronExpression = new CronExpression("0 0 12 1 1 ?"); + cronExpression.setTimeZone(TimeZone.getTimeZone("UTC")); + Date next = cronExpression.getNextValidTimeAfter( + Date.from(Instant.parse("2026-06-01T00:00:00Z"))); + System.out.print((CronExpression.MAX_YEAR - new GregorianCalendar().get(Calendar.YEAR)) + + " " + next.toInstant() + " " + cronExpression.isSatisfiedBy(next)); + } + + @Test(timeout = 60000) + public void testYearLimitIgnoresDefaultCalendarSystem() throws Exception { + String expected = "100 2027-01-01T12:00:00Z true"; + assertEquals(expected, runWithDefaultLocale("ja", "JP", "JP")); + assertEquals(expected, runWithDefaultLocale("th", "TH", "")); + } + + private static String runWithDefaultLocale(String language, String country, String variant) + throws Exception { + String classPath = location(CronExpression.class) + File.pathSeparator + + location(CronExpressionLocaleTest.class); + List command = Arrays.asList( + System.getProperty("java.home") + File.separator + "bin" + File.separator + "java", + "-Duser.language=" + language, "-Duser.country=" + country, + "-Duser.variant=" + variant, "-cp", classPath, CronExpressionLocaleTest.class.getName()); + ProcessBuilder builder = new ProcessBuilder(command); + Map environment = builder.environment(); + environment.remove("JAVA_TOOL_OPTIONS"); + environment.remove("_JAVA_OPTIONS"); + environment.remove("JDK_JAVA_OPTIONS"); + Process process = builder.start(); + String output = read(process.getInputStream()); + String error = read(process.getErrorStream()); + assertEquals(error, 0, process.waitFor()); + return output; + } + + private static String location(Class type) throws Exception { + return new File(type.getProtectionDomain().getCodeSource().getLocation().toURI()).getPath(); + } + + private static String read(InputStream in) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + for (int n = in.read(buffer); n != -1; n = in.read(buffer)) { + out.write(buffer, 0, n); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/framework/src/test/java/org/tron/common/cron/CronExpressionTest.java b/framework/src/test/java/org/tron/common/cron/CronExpressionTest.java index 1c8bdf86134..1fbe813ba9c 100644 --- a/framework/src/test/java/org/tron/common/cron/CronExpressionTest.java +++ b/framework/src/test/java/org/tron/common/cron/CronExpressionTest.java @@ -19,12 +19,16 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import java.text.ParseException; +import java.time.Instant; import java.util.Calendar; import java.util.Date; import java.util.Locale; +import java.util.TimeZone; import org.junit.Test; public class CronExpressionTest { @@ -72,6 +76,151 @@ public void testIsSatisfiedBy() throws Exception { cal = Calendar.getInstance(); cal.set(2005, Calendar.JUNE, 1, 10, 14, 0); assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.JUNE, 30, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.MARCH, 1, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(Calendar.DAY_OF_MONTH, 31); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.MARCH, 1, 10, 16, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.MARCH, 1, 10, 14, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.FEBRUARY, 1, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(Calendar.DAY_OF_MONTH, 28); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.FEBRUARY, 1, 10, 16, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.FEBRUARY, 1, 10, 14, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 12 * ? 2005"); + cal.set(2005, Calendar.DECEMBER, 12, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 11, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 13, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + } + + @Test + public void testIsSatisfiedByLastDayOfMonth() throws Exception { + Calendar cal = Calendar.getInstance(); + + CronExpression cronExpression = new CronExpression("0 15 10 L * ? 2005"); + cal.set(2005, Calendar.DECEMBER, 31, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 30, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 30, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 29, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L 2 ? 2005"); + cal.set(2005, Calendar.FEBRUARY, 28, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.FEBRUARY, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L 2 ? 2004"); + cal.set(2004, Calendar.FEBRUARY, 29, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2004, Calendar.FEBRUARY, 28, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + } + + @Test + public void testIsSatisfiedByLastDayOfMonthWithOffset() throws Exception { + Calendar cal = Calendar.getInstance(); + + CronExpression cronExpression = new CronExpression("0 15 10 L-2 * ? 2005"); + cal.set(2005, Calendar.DECEMBER, 29, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 28, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 30, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 31, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L-1 * ? 2005"); + cal.set(2005, Calendar.SEPTEMBER, 29, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 28, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 30, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L-3 2 ? 2005"); + cal.set(2005, Calendar.FEBRUARY, 25, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.FEBRUARY, 24, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.FEBRUARY, 26, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.FEBRUARY, 28, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L-3 2 ? 2000"); + cal.set(2000, Calendar.FEBRUARY, 26, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2000, Calendar.FEBRUARY, 25, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2000, Calendar.FEBRUARY, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2000, Calendar.FEBRUARY, 29, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + } + + @Test + public void testNextValidTimeAfterLastDayOfShortMonth() throws Exception { + assertNextFireTimesInUtc("0 0 0 L 4 ? 2027", "2027-04-01T00:00:00Z", "2027-04-30T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L * ? 2027", "2027-04-01T00:00:00Z", "2027-04-30T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L-1 4 ? 2027", "2027-04-01T00:00:00Z", + "2027-04-29T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L 2 ? 2028", "2027-04-01T00:00:00Z", "2028-02-29T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L 2 ? 2027", "2027-01-01T00:00:00Z", "2027-02-28T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L-3 2 ? 2028", "2027-04-01T00:00:00Z", + "2028-02-26T00:00:00Z"); } @Test @@ -100,7 +249,27 @@ public void testIsValidExpression() { assertFalse(CronExpression.isValidExpression(" 0 15 10 ? * 6#3 2014-2012")); assertTrue(CronExpression.isValidExpression("0 0 20-18 ? * MON,WED,FRI")); assertTrue(CronExpression.isValidExpression("0 0/30 17-9 * 10-9 ? 2022")); + assertTrue(CronExpression.isValidExpression("* * * * * ?")); + assertTrue(CronExpression.isValidExpression("0 * 5 * * ?")); + assertTrue(CronExpression.isValidExpression("0 15 10 L-1W,L-1 * ? 2010")); + assertFalse(CronExpression.isValidExpression("Ralf 30 * * * ?")); + assertFalse(CronExpression.isValidExpression("0 30 Ralf * * ?")); + assertFalse(CronExpression.isValidExpression("kilroy was here")); + assertFalse(CronExpression.isValidExpression("L 30 * * * ?")); + assertFalse(CronExpression.isValidExpression("0 L * * * ?")); + assertFalse(CronExpression.isValidExpression("0 0 L * * ?")); + } + @Test + public void testIncrementRangeBoundary() { + assertEquals("Increment >= 60 : 60", assertThrows(ParseException.class, + () -> new CronExpression("/60 0 8-18 ? * 2-6")).getMessage()); + assertEquals("Increment >= 60 : 60", assertThrows(ParseException.class, + () -> new CronExpression("0 /60 8-18 ? * 2-6")).getMessage()); + assertEquals("Increment >= 24 : 24", assertThrows(ParseException.class, + () -> new CronExpression("0 0 /24 ? * 2-6")).getMessage()); + assertEquals("Increment > 12 : 13", assertThrows(ParseException.class, + () -> new CronExpression("0 0 0 ? /13 2-6")).getMessage()); } @Test @@ -116,11 +285,41 @@ public void testLastDayOffset() throws Exception { cal.set(2010, Calendar.OCTOBER, 28, 10, 15, 0); assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + // last day - 2 for February + cal.set(2010, Calendar.FEBRUARY, 26, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.FEBRUARY, 25, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + // last day - 2 for June + cal.set(2010, Calendar.JUNE, 28, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.JUNE, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + cronExpression = new CronExpression("0 15 10 L-5W * ? 2010"); // last day - 5 cal.set(2010, Calendar.OCTOBER, 26, 10, 15, 0); assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + cal.set(2010, Calendar.OCTOBER, 25, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.OCTOBER, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + // last day - 5 (September has 30 days) + cal.set(2010, Calendar.SEPTEMBER, 24, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.SEPTEMBER, 23, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.SEPTEMBER, 25, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + cronExpression = new CronExpression("0 15 10 L-1 * ? 2010"); // last day - 1 cal.set(2010, Calendar.OCTOBER, 30, 10, 15, 0); @@ -142,12 +341,28 @@ public void testLastDayOffset() throws Exception { cal.set(2010, Calendar.OCTOBER, 30, 10, 15, 0); assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + cal.set(2010, Calendar.FEBRUARY, 1, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.FEBRUARY, 28, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.FEBRUARY, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + cronExpression = new CronExpression("0 15 10 L-1W,L-1 * ? 2010"); // nearest weekday to last day - 1 (29th is a friday in 2010) cal.set(2010, Calendar.OCTOBER, 29, 10, 15, 0); assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); // last day - 1 cal.set(2010, Calendar.OCTOBER, 30, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + // nearest weekday to last day - 1 (26th is a friday in 2010) + cal.set(2010, Calendar.FEBRUARY, 26, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + // last day - 1 + cal.set(2010, Calendar.FEBRUARY, 27, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); cronExpression = new CronExpression("0 15 10 2W,16 * ? 2010"); // nearest weekday to the 2nd of the month (1st is a friday in 2010) @@ -159,7 +374,12 @@ public void testLastDayOffset() throws Exception { cal.set(2010, Calendar.OCTOBER, 16, 10, 15, 0); assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + // 2nd is a Tuesday in November 2010 + cal.set(2010, Calendar.NOVEMBER, 2, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + cal.set(2010, Calendar.NOVEMBER, 16, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); } @Test @@ -262,7 +482,7 @@ public void testQtz96() throws ParseException { new CronExpression("/120 0 8-18 ? * 2-6"); fail("Cron did not validate bad range interval in '_blank/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 60 : 120", e.getMessage()); + assertEquals("Increment >= 60 : 120", e.getMessage()); } // Test case 2 @@ -270,7 +490,7 @@ public void testQtz96() throws ParseException { new CronExpression("0/120 0 8-18 ? * 2-6"); fail("Cron did not validate bad range interval in in '0/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 60 : 120", e.getMessage()); + assertEquals("Increment >= 60 : 120", e.getMessage()); } // Test case 3 @@ -294,7 +514,7 @@ public void testQtz96() throws ParseException { new CronExpression("0 /120 8-18 ? * 2-6"); fail("Cron did not validate bad range interval in '_blank/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 60 : 120", e.getMessage()); + assertEquals("Increment >= 60 : 120", e.getMessage()); } // Test case 2 @@ -302,7 +522,7 @@ public void testQtz96() throws ParseException { new CronExpression("0 0/120 8-18 ? * 2-6"); fail("Cron did not validate bad range interval in in '0/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 60 : 120", e.getMessage()); + assertEquals("Increment >= 60 : 120", e.getMessage()); } // Test case 3 @@ -326,7 +546,7 @@ public void testQtz96() throws ParseException { new CronExpression("0 0 /120 ? * 2-6"); fail("Cron did not validate bad range interval in '_blank/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 24 : 120", e.getMessage()); + assertEquals("Increment >= 24 : 120", e.getMessage()); } // Test case 2 @@ -334,7 +554,7 @@ public void testQtz96() throws ParseException { new CronExpression("0 0 0/120 ? * 2-6"); fail("Cron did not validate bad range interval in in '0/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 24 : 120", e.getMessage()); + assertEquals("Increment >= 24 : 120", e.getMessage()); } // Test case 3 @@ -448,4 +668,219 @@ public void testQtz96() throws ParseException { } } + @Test + public void test1420() throws Exception { + Date after = localTime(2025, Calendar.NOVEMBER, 18, 10, 15, 0); + + assertNextFireTimes(new CronExpression("0 0 0 1W * ?"), after, + localTime(2025, Calendar.DECEMBER, 1, 0, 0, 0), + localTime(2026, Calendar.JANUARY, 1, 0, 0, 0), + localTime(2026, Calendar.FEBRUARY, 2, 0, 0, 0), + localTime(2026, Calendar.MARCH, 2, 0, 0, 0), + localTime(2026, Calendar.APRIL, 1, 0, 0, 0), + localTime(2026, Calendar.MAY, 1, 0, 0, 0), + localTime(2026, Calendar.JUNE, 1, 0, 0, 0), + localTime(2026, Calendar.JULY, 1, 0, 0, 0), + localTime(2026, Calendar.AUGUST, 3, 0, 0, 0), + localTime(2026, Calendar.SEPTEMBER, 1, 0, 0, 0), + localTime(2026, Calendar.OCTOBER, 1, 0, 0, 0), + localTime(2026, Calendar.NOVEMBER, 2, 0, 0, 0), + localTime(2026, Calendar.DECEMBER, 1, 0, 0, 0)); + + assertNextFireTimes(new CronExpression("59 59 23 L * ?"), after, + localTime(2025, Calendar.NOVEMBER, 30, 23, 59, 59), + localTime(2025, Calendar.DECEMBER, 31, 23, 59, 59), + localTime(2026, Calendar.JANUARY, 31, 23, 59, 59), + localTime(2026, Calendar.FEBRUARY, 28, 23, 59, 59), + localTime(2026, Calendar.MARCH, 31, 23, 59, 59), + localTime(2026, Calendar.APRIL, 30, 23, 59, 59), + localTime(2026, Calendar.MAY, 31, 23, 59, 59), + localTime(2026, Calendar.JUNE, 30, 23, 59, 59), + localTime(2026, Calendar.JULY, 31, 23, 59, 59), + localTime(2026, Calendar.AUGUST, 31, 23, 59, 59), + localTime(2026, Calendar.SEPTEMBER, 30, 23, 59, 59), + localTime(2026, Calendar.OCTOBER, 31, 23, 59, 59), + localTime(2026, Calendar.NOVEMBER, 30, 23, 59, 59), + localTime(2026, Calendar.DECEMBER, 31, 23, 59, 59)); + + assertTrue(CronExpression.isValidExpression("0 0 0 1W * ?")); + assertTrue(CronExpression.isValidExpression("59 59 23 L * ?")); + } + + @Test + public void testNearestWeekdayOnLaterDayStartsAtFirstHour() throws Exception { + assertNextFireTimesInUtc("0 0 0,12 15W * ?", "2027-06-01T00:01:00Z", + "2027-06-15T00:00:00Z", "2027-06-15T12:00:00Z", "2027-07-15T00:00:00Z"); + } + + @Test + public void testNearestWeekdayOnLaterDayStartsAtFirstMinute() throws Exception { + assertNextFireTimesInUtc("0 * * 15W * ?", "2027-06-01T00:01:00Z", + "2027-06-15T00:00:00Z", "2027-06-15T00:01:00Z"); + } + + @Test + public void testNearestWeekdayOnLaterDayStartsAtFirstSecond() throws Exception { + assertNextFireTimesInUtc("*/20 * * 15W * ?", "2027-06-01T00:01:00Z", + "2027-06-15T00:00:00Z", "2027-06-15T00:00:20Z", "2027-06-15T00:00:40Z"); + } + + @Test + public void testNearestWeekdayMovedBackStartsAtFirstFireTime() throws Exception { + assertNextFireTimesInUtc("0 0 0,12 15W * ?", "2026-08-01T00:01:00Z", + "2026-08-14T00:00:00Z", "2026-08-14T12:00:00Z", "2026-09-15T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0,12 15W * ?", "2026-08-15T00:00:00Z", + "2026-09-15T00:00:00Z"); + assertNextFireTimesInUtc("0 0 12 LW 10 ? 2027", "2027-10-01T00:00:00Z", + "2027-10-29T12:00:00Z"); + } + + @Test + public void testLastWeekdayOnLaterDayStartsAtFirstFireTime() throws Exception { + assertNextFireTimesInUtc("0 0 0,12 LW * ?", "2027-07-01T00:01:00Z", + "2027-07-30T00:00:00Z", "2027-07-30T12:00:00Z", "2027-08-31T00:00:00Z"); + } + + @Test + public void testLastDayOffsetWeekdayOnLaterDayStartsAtFirstFireTime() throws Exception { + assertNextFireTimesInUtc("0 0/30 9 L-2W * ?", "2027-07-01T09:10:00Z", + "2027-07-29T09:00:00Z", "2027-07-29T09:30:00Z"); + assertNextFireTimesInUtc("0 0 0,12 L-1W 10 ? 2027", "2027-10-01T00:00:00Z", + "2027-10-29T00:00:00Z", "2027-10-29T12:00:00Z"); + assertNextFireTimesInUtc("0 0 0,12 L-2W 8 ? 2027", "2027-08-01T00:00:00Z", + "2027-08-30T00:00:00Z", "2027-08-30T12:00:00Z"); + } + + @Test + public void testNearestWeekdayMovedForwardIsSatisfied() throws Exception { + assertNextFireTimesInUtc("0 0 0 1W 5 ? 2027", "2027-04-30T12:00:00Z", + "2027-05-03T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 1W 5 ? 2027", "2027-05-02T00:00:00Z", + "2027-05-03T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0,12 15W * ?", "2027-08-01T00:00:00Z", + "2027-08-16T00:00:00Z", "2027-08-16T12:00:00Z", "2027-09-15T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 1W * ?", "2027-05-01T00:00:00Z", "2027-05-03T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0,12 1W 5 ? 2027", "2027-05-03T00:00:00Z", + "2027-05-03T12:00:00Z"); + } + + @Test + public void testLastDayOffsetBeforeFixedDayInShortMonth() throws Exception { + assertNextFireTimesInUtc("0 0 12 L-3,26 2 ? 2022", "2022-02-01T00:00:00Z", + "2022-02-25T12:00:00Z", "2022-02-26T12:00:00Z"); + assertNextFireTimesInUtc("0 0 12 27W,L-3W 2 ? 2022", "2022-02-01T00:00:00Z", + "2022-02-25T12:00:00Z", "2022-02-28T12:00:00Z"); + } + + @Test + public void testNearestWeekdayListDoesNotSkipMonth() throws Exception { + assertNextFireTimesInUtc("0 0 10 3W,20W 4 ?", "2027-04-02T10:00:00Z", + "2027-04-20T10:00:00Z", "2028-04-03T10:00:00Z"); + } + + @Test(timeout = 10000) + public void testNearestWeekdayInDaylightSavingGapTerminates() throws Exception { + CronExpression cronExpression = new CronExpression("0 30 1 29W 2 ?"); + cronExpression.setTimeZone(TimeZone.getTimeZone("Asia/Jerusalem")); + Date after = Date.from(Instant.parse("2003-02-28T12:00:00Z")); + + Date next = cronExpression.getNextValidTimeAfter(after); + assertEquals(Instant.parse("2004-02-26T23:30:00Z"), next.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(next)); + } + + @Test + public void testNearestWeekdayBeforeLaterDayOfMonthStillFires() throws Exception { + assertNextFireTimesInUtc("18 0 22 L,3W,LW 3 ? 2001", "2001-03-02T22:00:18Z", + "2001-03-30T22:00:18Z", "2001-03-31T22:00:18Z"); + assertNextFireTimesInUtc("0 0 10 5,20W 4 ? 2027", "2027-04-01T00:00:00Z", + "2027-04-05T10:00:00Z", "2027-04-20T10:00:00Z"); + } + + @Test(timeout = 10000) + public void testNearestWeekdayDoesNotFireInDaylightSavingGap() throws Exception { + TimeZone amman = TimeZone.getTimeZone("Asia/Amman"); + Date after = Date.from(Instant.parse("2021-03-20T00:00:00Z")); + + CronExpression cronExpression = new CronExpression("0 30 0 27W 3 ? 2021"); + cronExpression.setTimeZone(amman); + assertNull(cronExpression.getNextValidTimeAfter(after)); + assertFalse(cronExpression.isSatisfiedBy(Date.from(Instant.parse("2021-03-25T22:30:00Z")))); + + cronExpression = new CronExpression("0 30 0,5 27W 3 ? 2021"); + cronExpression.setTimeZone(amman); + Date next = cronExpression.getNextValidTimeAfter(after); + assertEquals(Instant.parse("2021-03-26T02:30:00Z"), next.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(next)); + } + + @Test(timeout = 10000) + public void testNearestWeekdayOnSkippedDayNeverGoesBackwards() throws Exception { + CronExpression cronExpression = new CronExpression("0 5 0,2 LW,L,1 12 ? 2011"); + cronExpression.setTimeZone(TimeZone.getTimeZone("Pacific/Apia")); + Date after = Date.from(Instant.parse("2011-12-30T10:05:00Z")); + + Date next = cronExpression.getNextValidTimeAfter(after); + assertEquals(Instant.parse("2011-12-30T12:05:00Z"), next.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(next)); + } + + @Test + public void testNearestWeekdayIgnoresDefaultCalendarSystem() throws Exception { + Locale defaultLocale = Locale.getDefault(); + try { + for (Locale locale : new Locale[] {new Locale("th", "TH"), new Locale("ja", "JP", "JP")}) { + Locale.setDefault(locale); + assertNextFireTimesInUtc("0 0 0 15W * ?", "2027-06-01T00:00:00Z", + "2027-06-15T00:00:00Z", "2027-07-15T00:00:00Z"); + } + } finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + public void testNextFireTimeIgnoresMilliseconds() throws Exception { + CronExpression cronExpression = new CronExpression("* * * * * ?"); + cronExpression.setTimeZone(TimeZone.getTimeZone("UTC")); + Date next = cronExpression.getNextValidTimeAfter( + Date.from(Instant.parse("2027-06-01T00:00:00.700Z"))); + assertEquals(Instant.parse("2027-06-01T00:00:01Z"), next.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(next)); + + cronExpression = new CronExpression("0 0 12 * * ?"); + cronExpression.setTimeZone(TimeZone.getTimeZone("UTC")); + assertTrue(cronExpression.isSatisfiedBy(Date.from(Instant.parse("2027-06-01T12:00:00.500Z")))); + assertFalse(cronExpression.isSatisfiedBy(Date.from(Instant.parse("2027-06-01T12:00:01Z")))); + } + + private static Date localTime(int year, int month, int day, int hour, int minute, + int second) { + Calendar cal = Calendar.getInstance(); + cal.clear(); + cal.set(year, month, day, hour, minute, second); + return cal.getTime(); + } + + private static void assertNextFireTimes(CronExpression cronExpression, Date after, + Date... expected) { + Date time = after; + for (Date next : expected) { + time = cronExpression.getTimeAfter(time); + assertEquals(next, time); + assertTrue(cronExpression.isSatisfiedBy(time)); + } + } + + private static void assertNextFireTimesInUtc(String expression, String after, + String... expected) throws ParseException { + CronExpression cronExpression = new CronExpression(expression); + cronExpression.setTimeZone(TimeZone.getTimeZone("UTC")); + Date time = Date.from(Instant.parse(after)); + for (String next : expected) { + time = cronExpression.getNextValidTimeAfter(time); + assertEquals(Instant.parse(next), time.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(time), next); + } + } } diff --git a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java index 07714b240ad..2cca9082cc3 100644 --- a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java +++ b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java @@ -29,9 +29,12 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.InetAddress; +import java.time.Instant; import java.util.Arrays; +import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.TimeZone; import lombok.extern.slf4j.Slf4j; import org.junit.After; import org.junit.Assert; @@ -41,6 +44,7 @@ import org.slf4j.LoggerFactory; import org.tron.common.TestConstants; import org.tron.common.args.GenesisBlock; +import org.tron.common.cron.CronExpression; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.DecodeUtil; @@ -196,6 +200,32 @@ public void testIpFromLibP2p() Assert.assertNotEquals(configuredExternalIp, parameter.getNodeExternalIp()); } + @Test + public void testShutdownBlockTimeEvaluatedInUtc() { + TimeZone defaultZone = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + Map params = new HashMap<>(); + params.put("node.shutdown.BlockTime", "0 0 0 L 4 ? 2027"); + params.put("storage.db.directory", "database"); + Config config = ConfigFactory.defaultOverrides() + .withFallback(ConfigFactory.parseMap(params)) + .withFallback(ConfigFactory.defaultReference()); + Args.applyConfigParams(config); + + CronExpression blockTime = Args.getInstance().getShutdownBlockTime(); + Assert.assertEquals("UTC", blockTime.getTimeZone().getID()); + Date lastDay = Date.from(Instant.parse("2027-04-30T00:00:00Z")); + Assert.assertEquals(lastDay, + blockTime.getNextValidTimeAfter(Date.from(Instant.parse("2027-04-01T00:00:00Z")))); + Assert.assertTrue(blockTime.isSatisfiedBy(lastDay)); + Assert.assertFalse(blockTime.isSatisfiedBy( + Date.from(Instant.parse("2027-04-29T16:00:00Z")))); + } finally { + TimeZone.setDefault(defaultZone); + } + } + @Test public void testInitService() { Map storage = new HashMap<>(); diff --git a/framework/src/test/java/org/tron/core/db/ManagerAutoStopTest.java b/framework/src/test/java/org/tron/core/db/ManagerAutoStopTest.java new file mode 100644 index 00000000000..d5c083c6bb5 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db/ManagerAutoStopTest.java @@ -0,0 +1,236 @@ +package org.tron.core.db; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.text.ParseException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.NoSuchElementException; +import java.util.TimeZone; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.tron.common.TestConstants; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.cron.CronExpression; +import org.tron.common.exit.ExitManager; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.utils.ReflectUtils; +import org.tron.core.ChainBaseManager; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.exception.TronError; + +public class ManagerAutoStopTest { + + private static final String PREVIEW_PREFIX = "Auto-stop: shutDownBlockTime "; + private static final String CANDIDATE_NOTE = "These are candidates: the node stops only if " + + "a block timestamp exactly matches one, and that is not guaranteed."; + private static final long HEAD_TIME = Instant.parse("2026-06-01T00:00:00Z").toEpochMilli(); + private static final String HEAD_TEXT = "Mon 2026-06-01 00:00:00 UTC"; + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final Logger dbLogger = (Logger) LoggerFactory.getLogger("DB"); + private final ListAppender dbLog = new ListAppender<>(); + private Manager startupManager; + + @Before + public void attachLog() { + dbLog.start(); + dbLogger.addAppender(dbLog); + } + + @After + public void cleanUp() { + dbLogger.detachAppender(dbLog); + Mockito.framework().clearInlineMocks(); + if (startupManager != null) { + startupManager.close(); + } + Args.clearParam(); + } + + @Test + public void singleFutureMatchIsPreviewedAndNotRejected() throws Exception { + CronExpression cron = utcCron("0 0 12 15 6 ? 2027"); + + runInitAutoStop(HEAD_TIME, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 15 6 ? 2027", HEAD_TEXT, + "Tue 2027-06-15 12:00:00 UTC")), previews()); + verify(cron, times(2)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void twoFutureMatchesEndThePreviewAtNull() throws Exception { + CronExpression cron = utcCron("0 0 12 15 6 ? 2027-2028"); + + runInitAutoStop(HEAD_TIME, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 15 6 ? 2027-2028", HEAD_TEXT, + "Tue 2027-06-15 12:00:00 UTC", "Thu 2028-06-15 12:00:00 UTC")), previews()); + verify(cron, times(3)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void thirdMatchEndsThePreviewWithoutAFourthQuery() throws Exception { + CronExpression cron = utcCron("0 0 12 15 6 ? 2027-2029"); + + runInitAutoStop(HEAD_TIME, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 15 6 ? 2027-2029", HEAD_TEXT, + "Tue 2027-06-15 12:00:00 UTC", "Thu 2028-06-15 12:00:00 UTC", + "Fri 2029-06-15 12:00:00 UTC")), previews()); + verify(cron, times(3)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void moreThanThreeFutureMatchesPreviewOnlyThree() throws Exception { + CronExpression cron = utcCron("0 0 12 * * ?"); + + runInitAutoStop(HEAD_TIME, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 * * ?", HEAD_TEXT, + "Mon 2026-06-01 12:00:00 UTC", "Tue 2026-06-02 12:00:00 UTC", + "Wed 2026-06-03 12:00:00 UTC")), previews()); + verify(cron, times(3)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void noFutureMatchKeepsTheStartupRejection() throws Exception { + CronExpression cron = utcCron("0 0 12 1 1 ? 2020"); + long head = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(); + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> runInitAutoStop(head, 0L, cron)); + + assertEquals("shutDownBlockTime 0 0 12 1 1 ? 2020 is illegal", thrown.getMessage()); + assertTrue(previews().isEmpty()); + verify(cron, times(1)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void previewStartsFromTheHeadBlockTime() throws Exception { + CronExpression cron = utcCron("0 0 12 1 1 ? 2001"); + long head = Instant.parse("2000-01-01T00:00:00Z").toEpochMilli(); + + runInitAutoStop(head, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 1 1 ? 2001", + "Sat 2000-01-01 00:00:00 UTC", "Mon 2001-01-01 12:00:00 UTC")), previews()); + verify(cron).getNextValidTimeAfter(new Date(head)); + } + + @Test + public void previewIgnoresTheDefaultZoneAndLocale() throws Exception { + TimeZone defaultZone = TimeZone.getDefault(); + Locale defaultLocale = Locale.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + Locale.setDefault(new Locale("th", "TH")); + try { + runInitAutoStop(HEAD_TIME, 0L, utcCron("0 0 12 15 6 ? 2027")); + } finally { + TimeZone.setDefault(defaultZone); + Locale.setDefault(defaultLocale); + } + + assertEquals(Collections.singletonList(preview("0 0 12 15 6 ? 2027", HEAD_TEXT, + "Tue 2027-06-15 12:00:00 UTC")), previews()); + } + + @Test + public void withoutBlockTimeNothingIsPreviewed() throws Exception { + Manager dbManager = runInitAutoStop(HEAD_TIME, 100L, null); + + assertTrue(previews().isEmpty()); + assertEquals(100L, (long) ReflectUtils.getFieldValue(dbManager, "latestSolidityNumShutDown")); + } + + @Test + public void cronRuntimeFailureStopsStartupWithAutoStopParams() throws Exception { + Args.setParam(new String[] {"--output-directory", temporaryFolder.newFolder().toString()}, + TestConstants.TEST_CONF); + Args.getInstance().setShutdownBlockTime(new CronExpression("0 0 0 1 L ?")); + TronApplicationContext context = new TronApplicationContext(); + context.getDefaultListableBeanFactory().addBeanPostProcessor(new BeanPostProcessor() { + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) { + if (bean instanceof Manager) { + startupManager = (Manager) bean; + } + return bean; + } + }); + context.register(DefaultConfig.class); + + BeansException thrown = assertThrows(BeansException.class, context::refresh); + + TronError error = ExitManager.findTronError(thrown).orElseThrow(AssertionError::new); + assertEquals(TronError.ErrCode.AUTO_STOP_PARAMS, error.getErrCode()); + assertEquals(NoSuchElementException.class, error.getCause().getClass()); + assertTrue(dbLog.list.stream().map(ILoggingEvent::getFormattedMessage).anyMatch( + "Auto-stop params error: java.util.NoSuchElementException"::equals)); + } + + private CronExpression utcCron(String expression) throws ParseException { + CronExpression cron = new CronExpression(expression); + cron.setTimeZone(TimeZone.getTimeZone(ZoneOffset.UTC)); + return spy(cron); + } + + private Manager runInitAutoStop(long headTime, long exitHeight, CronExpression blockTime) { + ChainBaseManager chainBaseManager = mock(ChainBaseManager.class); + when(chainBaseManager.getHeadBlockNum()).thenReturn(10L); + when(chainBaseManager.getHeadBlockTimeStamp()).thenReturn(headTime); + Manager dbManager = new Manager(); + ReflectUtils.setFieldValue(dbManager, "chainBaseManager", chainBaseManager); + CommonParameter parameter = new CommonParameter(); + parameter.setShutdownBlockHeight(exitHeight); + parameter.setShutdownBlockCount(-1L); + parameter.setShutdownBlockTime(blockTime); + try (MockedStatic ignored = mockStatic(CommonParameter.class)) { + when(CommonParameter.getInstance()).thenReturn(parameter); + ReflectUtils.invokeMethod(dbManager, "initAutoStop"); + } + return dbManager; + } + + private List previews() { + return dbLog.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith(PREVIEW_PREFIX)) + .collect(Collectors.toList()); + } + + private static String preview(String expression, String head, String... matches) { + return PREVIEW_PREFIX + expression + ", head block time " + head + ", next cron matches " + + Arrays.toString(matches) + ". " + CANDIDATE_NOTE; + } +} diff --git a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java index ab67437dfe7..c2495c03082 100644 --- a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java +++ b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java @@ -188,6 +188,18 @@ public void shutdownBlockTimeInitTest() { assertEquals(TronError.ErrCode.AUTO_STOP_PARAMS, thrown.getErrCode()); } + @Test + public void shutdownBlockTimeLastDayInSecondsTest() { + Map params = new HashMap<>(); + params.put("node.shutdown.BlockTime", "L 30 * * * ?"); + params.put("storage.db.directory", "database"); + Config config = ConfigFactory.defaultOverrides() + .withFallback(ConfigFactory.parseMap(params)) + .withFallback(ConfigFactory.defaultReference()); + TronError thrown = assertThrows(TronError.class, () -> Args.applyConfigParams(config)); + assertEquals(TronError.ErrCode.AUTO_STOP_PARAMS, thrown.getErrCode()); + } + @Test public void testThrowIfUnsupportedJavaVersion() { runArchTest("x86_64", "1.8", false); diff --git a/framework/src/test/java/org/tron/core/services/stop/BlockStopOverlapTest.java b/framework/src/test/java/org/tron/core/services/stop/BlockStopOverlapTest.java new file mode 100644 index 00000000000..6f5b949ba1b --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/stop/BlockStopOverlapTest.java @@ -0,0 +1,108 @@ +package org.tron.core.services.stop; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.capsule.BlockCapsule; + +public class BlockStopOverlapTest extends ConditionallyStopTest { + + private static final long HEIGHT = 5; + + private final AtomicReference waitingError = new AtomicReference<>(); + + @Override + protected void initParameter(CommonParameter parameter) { + parameter.setShutdownBlockHeight(HEIGHT); + parameter.getStorage().setMaxFlushCount(1); + } + + @Override + protected void check() { + assertEquals(HEIGHT, persistedHeight()); + } + + @Test(timeout = 30_000) + public void blockWaitingForTheLockStopsAtTheTargetHeight() throws Exception { + BlockCapsule first = nextBlockBeforeTargetIsPersisted(); + BlockCapsule second = blockAfter(first); + Thread waiting; + + synchronized (tronNetDelegate.getBlockLock()) { + waiting = startWaitingForTheLock(second); + tronNetDelegate.processBlock(first, false); + assertEquals(HEIGHT, persistedHeight()); + } + waiting.join(); + + assertNull(waitingError.get()); + assertTrue(tronNetDelegate.isHitDown()); + assertStoppedAfter(first); + } + + @Test(timeout = 30_000) + public void blockWaitingForTheLockIsDroppedAfterTheStopSignal() throws Exception { + BlockCapsule first = nextBlockBeforeTargetIsPersisted(); + BlockCapsule second = blockAfter(first); + Thread waiting; + + synchronized (tronNetDelegate.getBlockLock()) { + waiting = startWaitingForTheLock(second); + tronNetDelegate.processBlock(first, false); + tronNetDelegate.processBlock(second, false); + assertTrue(tronNetDelegate.isHitDown()); + } + waiting.join(); + + assertNull(waitingError.get()); + assertStoppedAfter(first); + } + + private BlockCapsule nextBlockBeforeTargetIsPersisted() throws Exception { + while (persistedHeight() < HEIGHT - 1) { + generateBlock(); + } + return createTestBlockCapsule( + chainManager.getNextBlockSlotTime(), + headHeight() + 1, + chainManager.getDynamicPropertiesStore().getLatestBlockHeaderHash().getByteString()); + } + + private BlockCapsule blockAfter(BlockCapsule block) { + return createTestBlockCapsule(block.getTimeStamp() + 3000, block.getNum() + 1, + block.getBlockId().getByteString()); + } + + private Thread startWaitingForTheLock(BlockCapsule block) throws InterruptedException { + Thread thread = new Thread(() -> { + try { + tronNetDelegate.processBlock(block, false); + } catch (Throwable t) { + waitingError.set(t); + } + }, "waiting-block"); + thread.start(); + while (thread.getState() != Thread.State.BLOCKED) { + assertTrue(thread.isAlive()); + Thread.sleep(1); + } + return thread; + } + + private void assertStoppedAfter(BlockCapsule block) { + assertEquals(HEIGHT, persistedHeight()); + assertEquals(block.getNum(), headHeight()); + } + + private long persistedHeight() { + return dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumberFromDB(); + } + + private long headHeight() { + return dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumber(); + } +} diff --git a/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopDefaultFlushCountTest.java b/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopDefaultFlushCountTest.java new file mode 100644 index 00000000000..c15f6faa7f8 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopDefaultFlushCountTest.java @@ -0,0 +1,12 @@ +package org.tron.core.services.stop; + +import org.tron.common.parameter.CommonParameter; + +public class BlockTimeStopDefaultFlushCountTest extends BlockTimeStopTest { + + @Override + protected void initParameter(CommonParameter parameter) { + super.initParameter(parameter); + parameter.getStorage().setMaxFlushCount(1); + } +} diff --git a/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopLargerFlushCountTest.java b/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopLargerFlushCountTest.java new file mode 100644 index 00000000000..31fa054e296 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopLargerFlushCountTest.java @@ -0,0 +1,12 @@ +package org.tron.core.services.stop; + +import org.tron.common.parameter.CommonParameter; + +public class BlockTimeStopLargerFlushCountTest extends BlockTimeStopTest { + + @Override + protected void initParameter(CommonParameter parameter) { + super.initParameter(parameter); + parameter.getStorage().setMaxFlushCount(20); + } +} diff --git a/framework/src/test/java/org/tron/core/services/stop/ConditionallyStopTest.java b/framework/src/test/java/org/tron/core/services/stop/ConditionallyStopTest.java index 964cfbf254d..45a22a35621 100644 --- a/framework/src/test/java/org/tron/core/services/stop/ConditionallyStopTest.java +++ b/framework/src/test/java/org/tron/core/services/stop/ConditionallyStopTest.java @@ -38,7 +38,7 @@ public abstract class ConditionallyStopTest extends BaseMethodTest { private static DposSlot dposSlot; private final AtomicInteger port = new AtomicInteger(0); long currentHeader = -1; - private TronNetDelegate tronNetDelegate; + protected TronNetDelegate tronNetDelegate; private DposService dposService; private ConsensusDelegate consensusDelegate; @@ -90,7 +90,7 @@ protected void afterInit() { chainManager.getDynamicPropertiesStore().saveNextMaintenanceTime(time); } - private void generateBlock() throws Exception { + protected void generateBlock() throws Exception { BlockCapsule block = createTestBlockCapsule( @@ -132,7 +132,7 @@ private Map addTestWitnessAndAccount() { .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } - private BlockCapsule createTestBlockCapsule(long time, + protected BlockCapsule createTestBlockCapsule(long time, long number, ByteString hash) { long slot = dposSlot.getSlot(time); ByteString witness = dposSlot.getScheduledWitness(slot);