From 1635218b1d8984f2a6a5cd68058d5ae407f9ff15 Mon Sep 17 00:00:00 2001 From: Chip Kent <5250374+chipkent@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:53:56 -0600 Subject: [PATCH] feat: Only initialize BusinessCalendar caches when data is accessed and improve caching performance (#5378) Makes BusinessCalendar caches populate only when accessed to avoid slow worker initialization. Additionally, caching in various operations has been improved to yield better performance. Resolves #5377 Benchmark: ```groovy n = 1_000_000 npart = 10 t = emptyTable(n) .updateView("Part = ii%npart", "Today = todayLocalDate()") tp = t.partitionBy("Part").proxy() c = calendar() timeit = { name, table, query -> c.clearCache() start = System.currentTimeMillis() rst = table.update(query) end = System.currentTimeMillis() println("TIME: ${name}: ${end-start} ms") } istart = parseInstant("2022-01-01T01:01:01 ET") iend = parseInstant("2024-06-01T01:01:01 ET") timeit("calendarDayOld-NORMAL", t, "CD = c.calendarDay(Today)") timeit("calendarDayOld-PART", tp, "CD = c.calendarDay(Today)") timeit("diffBusinessYearsOld-NORMAL", t, "CD = c.diffBusinessYears(istart, iend)") timeit("diffBusinessYearsOld-PART", tp, "CD = c.diffBusinessYears(istart, iend)") println("DONE") ``` Performance: ``` // 1M - NEW //TIME: calendarDayOld-NORMAL: 266 ms //TIME: calendarDayOld-PART: 299 ms //TIME: diffBusinessYearsOld-NORMAL: 5937 ms //TIME: diffBusinessYearsOld-PART: 6664 ms <<<< // 1M - v0.33.3 //TIME: calendarDayOld-NORMAL: 366 ms //TIME: calendarDayOld-PART: 327 ms //TIME: diffBusinessYearsOld-NORMAL: 20384 ms //TIME: diffBusinessYearsOld-PART: 5549 ms <<<< // 10M - NEW //TIME: calendarDayOld-NORMAL: 449 ms //TIME: calendarDayOld-PART: 548 ms //TIME: diffBusinessYearsOld-NORMAL: 58883 ms //TIME: diffBusinessYearsOld-PART: 67362 ms <<<< // 10M - v0.33.3 //TIME: calendarDayOld-NORMAL: 522 ms //TIME: calendarDayOld-PART: 704 ms //TIME: diffBusinessYearsOld-NORMAL: 195916 ms //TIME: diffBusinessYearsOld-PART: 49411 ms <<<< ``` Note1: `v0.33.0` times do not include initialization times, but the new values do. Note2: There appears to be contention or initialization overhead in the partitioned table case yielding slightly worse performance. --- .../libs/StaticCalendarMethodsGenerator.java | 1 + .../time/calendar/BusinessCalendar.java | 478 ++++++++++++++---- .../io/deephaven/time/calendar/Calendar.java | 118 ++++- .../deephaven/time/calendar/CalendarDay.java | 22 +- .../ReadOptimizedConcurrentCache.java | 126 +++++ .../time/calendar/YearMonthSummaryCache.java | 189 +++++++ .../time/calendar/TestBusinessCalendar.java | 229 ++++++++- .../deephaven/time/calendar/TestCalendar.java | 15 + .../time/calendar/TestCalendarDay.java | 4 +- .../TestReadOptimizedConcurrentCache.java | 56 ++ .../calendar/TestStaticCalendarMethods.java | 1 + .../calendar/TestYearMonthSummaryCache.java | 333 ++++++++++++ 12 files changed, 1445 insertions(+), 127 deletions(-) create mode 100644 engine/time/src/main/java/io/deephaven/time/calendar/ReadOptimizedConcurrentCache.java create mode 100644 engine/time/src/main/java/io/deephaven/time/calendar/YearMonthSummaryCache.java create mode 100644 engine/time/src/test/java/io/deephaven/time/calendar/TestReadOptimizedConcurrentCache.java create mode 100644 engine/time/src/test/java/io/deephaven/time/calendar/TestYearMonthSummaryCache.java diff --git a/Generators/src/main/java/io/deephaven/libs/StaticCalendarMethodsGenerator.java b/Generators/src/main/java/io/deephaven/libs/StaticCalendarMethodsGenerator.java index b22ed2a4b58..5f737258e47 100644 --- a/Generators/src/main/java/io/deephaven/libs/StaticCalendarMethodsGenerator.java +++ b/Generators/src/main/java/io/deephaven/libs/StaticCalendarMethodsGenerator.java @@ -79,6 +79,7 @@ public static void main(String[] args) throws ClassNotFoundException, IOExceptio excludes.add("description"); excludes.add("firstValidDate"); excludes.add("lastValidDate"); + excludes.add("clearCache"); StaticCalendarMethodsGenerator gen = new StaticCalendarMethodsGenerator(gradleTask, packageName, className, imports, diff --git a/engine/time/src/main/java/io/deephaven/time/calendar/BusinessCalendar.java b/engine/time/src/main/java/io/deephaven/time/calendar/BusinessCalendar.java index 3b95f92adae..66a0c6813fe 100644 --- a/engine/time/src/main/java/io/deephaven/time/calendar/BusinessCalendar.java +++ b/engine/time/src/main/java/io/deephaven/time/calendar/BusinessCalendar.java @@ -57,76 +57,188 @@ public InvalidDateException(final String message, final Throwable cause) { // region Cache - private final Map> cachedSchedules = new HashMap<>(); - private final Map cachedYearData = new HashMap<>(); + private static class SummaryData extends ReadOptimizedConcurrentCache.IntKeyedValue { + private final Instant startInstant; + private final LocalDate startDate; + private final Instant endInstant; + private final LocalDate endDate; // exclusive + private final long businessTimeNanos; + private final int businessDays; + private final int nonBusinessDays; + private final ArrayList businessDates; + private final ArrayList nonBusinessDates; + + public SummaryData( + final int key, + final Instant startInstant, + final LocalDate startDate, + final Instant endInstant, + final LocalDate endDate, + final long businessTimeNanos, + final int businessDays, + final int nonBusinessDays, + final ArrayList businessDates, + final ArrayList nonBusinessDates) { + super(key); + this.startInstant = startInstant; + this.startDate = startDate; + this.endInstant = endInstant; + this.endDate = endDate; + this.businessTimeNanos = businessTimeNanos; + this.businessDays = businessDays; + this.nonBusinessDays = nonBusinessDays; + this.businessDates = businessDates; + this.nonBusinessDates = nonBusinessDates; + } + } + + private final ReadOptimizedConcurrentCache>> schedulesCache = + new ReadOptimizedConcurrentCache<>(10000, this::computeCalendarDay); + private final YearMonthSummaryCache summaryCache = + new YearMonthSummaryCache<>(this::computeMonthSummary, this::computeYearSummary); + private final int yearCacheStart; + private final int yearCacheEnd; - private void populateSchedules() { - LocalDate date = firstValidDate; + @Override + synchronized void clearCache() { + super.clearCache(); + schedulesCache.clear(); + summaryCache.clear(); + } - while (!date.isAfter(lastValidDate)) { + private SummaryData summarize(final int key, final LocalDate startDate, final LocalDate endDate) { + final ZonedDateTime start = startDate.atTime(0, 0).atZone(timeZone()); + final ZonedDateTime end = endDate.atTime(0, 0).atZone(timeZone()); - final CalendarDay s = holidays.get(date); + LocalDate date = startDate; + long businessTimeNanos = 0; + int businessDays = 0; + int nonBusinessDays = 0; + final ArrayList businessDates = new ArrayList<>(); + final ArrayList nonBusinessDates = new ArrayList<>(); - if (s != null) { - cachedSchedules.put(date, s); - } else if (weekendDays.contains(date.getDayOfWeek())) { - cachedSchedules.put(date, CalendarDay.toInstant(CalendarDay.HOLIDAY, date, timeZone())); + while (date.isBefore(endDate)) { + final CalendarDay bs = calendarDay(date); + + if (bs.isBusinessDay()) { + ++businessDays; + businessDates.add(date); } else { - cachedSchedules.put(date, CalendarDay.toInstant(standardBusinessDay, date, timeZone())); + ++nonBusinessDays; + nonBusinessDates.add(date); } + businessTimeNanos += bs.businessNanos(); date = date.plusDays(1); } - } - private static class YearData { - private final Instant start; - private final Instant end; - private final long businessTimeNanos; + return new SummaryData( + key, + start.toInstant(), + start.toLocalDate(), + end.toInstant(), + end.toLocalDate(), + businessTimeNanos, + businessDays, + nonBusinessDays, + businessDates, + nonBusinessDates); + } + + private SummaryData computeMonthSummary(final int key) { + final int year = YearMonthSummaryCache.yearFromYearMonthKey(key); + final int month = YearMonthSummaryCache.monthFromYearMonthKey(key); + final LocalDate startDate = LocalDate.of(year, month, 1); + final LocalDate endDate = startDate.plusMonths(1); // exclusive + return summarize(key, startDate, endDate); + } + + private SummaryData computeYearSummary(final int year) { + Instant startInstant = null; + LocalDate startDate = null; + Instant endInstant = null; + LocalDate endDate = null; + long businessTimeNanos = 0; + int businessDays = 0; + int nonBusinessDays = 0; + ArrayList businessDates = new ArrayList<>(); + ArrayList nonBusinessDates = new ArrayList<>(); + + for (int month = 1; month <= 12; month++) { + SummaryData ms = summaryCache.getMonthSummary(year, month); + if (month == 1) { + startInstant = ms.startInstant; + startDate = ms.startDate; + } - public YearData(final Instant start, final Instant end, final long businessTimeNanos) { - this.start = start; - this.end = end; - this.businessTimeNanos = businessTimeNanos; + if (month == 12) { + endInstant = ms.endInstant; + endDate = ms.endDate; + } + + businessTimeNanos += ms.businessTimeNanos; + businessDays += ms.businessDays; + nonBusinessDays += ms.nonBusinessDays; + businessDates.addAll(ms.businessDates); + nonBusinessDates.addAll(ms.nonBusinessDates); } + + return new SummaryData( + year, + startInstant, + startDate, + endInstant, + endDate, + businessTimeNanos, + businessDays, + nonBusinessDays, + businessDates, + nonBusinessDates); } - private void populateCachedYearData() { - // Only cache complete years, since incomplete years can not be fully computed. + private SummaryData getYearSummary(final int year) { - final int yearStart = - firstValidDate.getDayOfYear() == 1 ? firstValidDate.getYear() : firstValidDate.getYear() + 1; - final int yearEnd = ((lastValidDate.isLeapYear() && lastValidDate.getDayOfYear() == 366) - || lastValidDate.getDayOfYear() == 365) ? lastValidDate.getYear() : lastValidDate.getYear() - 1; - - for (int year = yearStart; year <= yearEnd; year++) { - final LocalDate startDate = LocalDate.ofYearDay(year, 1); - final LocalDate endDate = LocalDate.ofYearDay(year + 1, 1); - final ZonedDateTime start = startDate.atTime(0, 0).atZone(timeZone()); - final ZonedDateTime end = endDate.atTime(0, 0).atZone(timeZone()); + if (year < yearCacheStart || year > yearCacheEnd) { + throw new InvalidDateException("Business calendar does not contain a complete year for: year=" + year); + } - LocalDate date = startDate; - long businessTimeNanos = 0; + return summaryCache.getYearSummary(year); + } - while (date.isBefore(endDate)) { - final CalendarDay bs = this.calendarDay(date); - businessTimeNanos += bs.businessNanos(); - date = date.plusDays(1); - } + /** + * Creates a key for the schedules cache from a date. + * + * @param date date + * @return key + */ + static int schedulesCacheKeyFromDate(final LocalDate date) { + return date.getYear() * 10000 + date.getMonthValue() * 100 + date.getDayOfMonth(); + } - final YearData yd = new YearData(start.toInstant(), end.toInstant(), businessTimeNanos); - cachedYearData.put(year, yd); - } + /** + * Creates a date from a schedules cache key. + * + * @param key key + * @return date + */ + static LocalDate schedulesCacheDateFromKey(final int key) { + return LocalDate.of(key / 10000, (key % 10000) / 100, key % 100); } - private YearData getYearData(final int year) { - final YearData yd = cachedYearData.get(year); + private ReadOptimizedConcurrentCache.Pair> computeCalendarDay(final int key) { + final LocalDate date = schedulesCacheDateFromKey(key); + final CalendarDay h = holidays.get(date); + final CalendarDay v; - if (yd == null) { - throw new InvalidDateException("Business calendar does not contain a complete year for: year=" + year); + if (h != null) { + v = h; + } else if (weekendDays.contains(date.getDayOfWeek())) { + v = CalendarDay.toInstant(CalendarDay.HOLIDAY, date, timeZone()); + } else { + v = CalendarDay.toInstant(standardBusinessDay, date, timeZone()); } - return yd; + return new ReadOptimizedConcurrentCache.Pair<>(key, v); } // endregion @@ -157,8 +269,12 @@ public BusinessCalendar(final String name, final String description, final ZoneI this.standardBusinessDay = Require.neqNull(standardBusinessDay, "standardBusinessDay"); this.weekendDays = Set.copyOf(Require.neqNull(weekendDays, "weekendDays")); this.holidays = Map.copyOf(Require.neqNull(holidays, "holidays")); - populateSchedules(); - populateCachedYearData(); + + // Only cache complete years, since incomplete years can not be fully computed. + yearCacheStart = + firstValidDate.getDayOfYear() == 1 ? firstValidDate.getYear() : firstValidDate.getYear() + 1; + yearCacheEnd = ((lastValidDate.isLeapYear() && lastValidDate.getDayOfYear() == 366) + || lastValidDate.getDayOfYear() == 365) ? lastValidDate.getYear() : lastValidDate.getYear() - 1; } // endregion @@ -254,7 +370,8 @@ public CalendarDay calendarDay(final LocalDate date) { + " lastValidDate=" + lastValidDate); } - return cachedSchedules.get(date); + final int key = schedulesCacheKeyFromDate(date); + return schedulesCache.computeIfAbsent(key).getValue(); } /** @@ -350,7 +467,7 @@ public boolean isBusinessDay(final String date) { /** * Is the time on a business day? - * + *

* As long as the time occurs on a business day, it is considered a business day. The time does not have to be * within the business day schedule. To determine if a time is within the business day schedule, use * {@link #isBusinessTime(ZonedDateTime)}. @@ -369,7 +486,7 @@ public boolean isBusinessDay(final ZonedDateTime time) { /** * Is the time on a business day? - * + *

* As long as the time occurs on a business day, it is considered a business day. The time does not have to be * within the business day schedule. To determine if a time is within the business day schedule, use * {@link #isBusinessTime(Instant)}. @@ -430,7 +547,7 @@ boolean isLastBusinessDayOfMonth(final LocalDate date) { /** * Is the time on the last business day of the month? - * + *

* As long as the time occurs on a business day, it is considered a business day. The time does not have to be * within the business day schedule. * @@ -450,7 +567,7 @@ public boolean isLastBusinessDayOfMonth(final ZonedDateTime time) { /** * Is the time on the last business day of the month? - * + *

* As long as the time occurs on a business day, it is considered a business day. The time does not have to be * within the business day schedule. * @@ -513,7 +630,7 @@ public boolean isLastBusinessDayOfWeek(final LocalDate date) { /** * Is the time on the last business day of the week? - * + *

* As long as the time occurs on a business day, it is considered a business day. The time does not have to be * within the business day schedule. * @@ -532,7 +649,7 @@ public boolean isLastBusinessDayOfWeek(final ZonedDateTime time) { /** * Is the time on the last business day of the week? - * + *

* As long as the time occurs on a business day, it is considered a business day. The time does not have to be * within the business day schedule. * @@ -595,7 +712,7 @@ boolean isLastBusinessDayOfYear(final LocalDate date) { /** * Is the time on the last business day of the year? - * + *

* As long as the time occurs on a business day, it is considered a business day. The time does not have to be * within the business day schedule. * @@ -614,7 +731,7 @@ public boolean isLastBusinessDayOfYear(final ZonedDateTime time) { /** * Is the time on the last business day of the year? - * + *

* As long as the time occurs on a business day, it is considered a business day. The time does not have to be * within the business day schedule. * @@ -651,7 +768,7 @@ boolean isLastBusinessDayOfYear(final String date) { /** * Is the current date the last business day of the year? - * + *

* As long as the current time occurs on a business day, it is considered a business day. The time does not have to * be within the business day schedule. * @@ -889,6 +1006,21 @@ public double fractionBusinessDayRemaining() { // region Ranges + private int numberBusinessDatesInternal(final LocalDate start, final LocalDate end, final boolean startInclusive, + final boolean endInclusive) { + int days = 0; + + for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { + final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + + if (!skip && isBusinessDay(day)) { + days++; + } + } + + return days; + } + /** * Returns the number of business dates in a given range. * @@ -906,14 +1038,30 @@ public int numberBusinessDates(final LocalDate start, final LocalDate end, final return NULL_INT; } + if (start.isAfter(end)) { + return 0; + } + + SummaryData summaryFirst = null; + SummaryData summary = null; int days = 0; - for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { - final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + for (Iterator it = summaryCache.iterator(start, end, startInclusive, endInclusive); it + .hasNext();) { + summary = it.next(); - if (!skip && isBusinessDay(day)) { - days++; + if (summaryFirst == null) { + summaryFirst = summary; } + + days += summary.businessDays; + } + + if (summaryFirst == null) { + return numberBusinessDatesInternal(start, end, startInclusive, endInclusive); + } else { + days += numberBusinessDatesInternal(start, summaryFirst.startDate, startInclusive, false); + days += numberBusinessDatesInternal(summary.endDate, end, true, endInclusive); } return days; @@ -1036,6 +1184,21 @@ public int numberBusinessDates(final Instant start, final Instant end) { return numberBusinessDates(start, end, true, true); } + private int numberNonBusinessDatesInternal(final LocalDate start, final LocalDate end, final boolean startInclusive, + final boolean endInclusive) { + int days = 0; + + for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { + final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + + if (!skip && !isBusinessDay(day)) { + days++; + } + } + + return days; + } + /** * Returns the number of non-business dates in a given range. * @@ -1053,8 +1216,33 @@ public int numberNonBusinessDates(final LocalDate start, final LocalDate end, fi return NULL_INT; } - return numberCalendarDates(start, end, startInclusive, endInclusive) - - numberBusinessDates(start, end, startInclusive, endInclusive); + if (start.isAfter(end)) { + return 0; + } + + SummaryData summaryFirst = null; + SummaryData summary = null; + int days = 0; + + for (Iterator it = summaryCache.iterator(start, end, startInclusive, endInclusive); it + .hasNext();) { + summary = it.next(); + + if (summaryFirst == null) { + summaryFirst = summary; + } + + days += summary.nonBusinessDays; + } + + if (summaryFirst == null) { + return numberNonBusinessDatesInternal(start, end, startInclusive, endInclusive); + } else { + days += numberNonBusinessDatesInternal(start, summaryFirst.startDate, startInclusive, false); + days += numberNonBusinessDatesInternal(summary.endDate, end, true, endInclusive); + } + + return days; } /** @@ -1174,6 +1362,18 @@ public int numberNonBusinessDates(final Instant start, final Instant end) { return numberNonBusinessDates(start, end, true, true); } + private void businessDatesInternal(final ArrayList result, final LocalDate start, final LocalDate end, + final boolean startInclusive, + final boolean endInclusive) { + for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { + final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + + if (!skip && isBusinessDay(day)) { + result.add(day); + } + } + } + /** * Returns the business dates in a given range. * @@ -1190,14 +1390,31 @@ public LocalDate[] businessDates(final LocalDate start, final LocalDate end, fin return null; } - List dateList = new ArrayList<>(); + if (start.isAfter(end)) { + return new LocalDate[0]; + } - for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { - final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + final ArrayList dateList = new ArrayList<>(); - if (!skip && isBusinessDay(day)) { - dateList.add(day); + SummaryData summaryFirst = null; + SummaryData summary = null; + + for (Iterator it = summaryCache.iterator(start, end, startInclusive, endInclusive); it + .hasNext();) { + summary = it.next(); + + if (summaryFirst == null) { + summaryFirst = summary; + businessDatesInternal(dateList, start, summaryFirst.startDate, startInclusive, false); } + + dateList.addAll(summary.businessDates); + } + + if (summaryFirst == null) { + businessDatesInternal(dateList, start, end, startInclusive, endInclusive); + } else { + businessDatesInternal(dateList, summary.endDate, end, true, endInclusive); } return dateList.toArray(new LocalDate[0]); @@ -1319,6 +1536,18 @@ public LocalDate[] businessDates(final Instant start, final Instant end) { return businessDates(start, end, true, true); } + private void nonBusinessDatesInternal(final ArrayList result, final LocalDate start, final LocalDate end, + final boolean startInclusive, + final boolean endInclusive) { + for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { + final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + + if (!skip && !isBusinessDay(day)) { + result.add(day); + } + } + } + /** * Returns the non-business dates in a given range. * @@ -1335,14 +1564,31 @@ public LocalDate[] nonBusinessDates(final LocalDate start, final LocalDate end, return null; } - List dateList = new ArrayList<>(); + if (start.isAfter(end)) { + return new LocalDate[0]; + } - for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { - final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + final ArrayList dateList = new ArrayList<>(); - if (!skip && !isBusinessDay(day)) { - dateList.add(day); + SummaryData summaryFirst = null; + SummaryData summary = null; + + for (Iterator it = summaryCache.iterator(start, end, startInclusive, endInclusive); it + .hasNext();) { + summary = it.next(); + + if (summaryFirst == null) { + summaryFirst = summary; + nonBusinessDatesInternal(dateList, start, summaryFirst.startDate, startInclusive, false); } + + dateList.addAll(summary.nonBusinessDates); + } + + if (summaryFirst == null) { + nonBusinessDatesInternal(dateList, start, end, startInclusive, endInclusive); + } else { + nonBusinessDatesInternal(dateList, summary.endDate, end, true, endInclusive); } return dateList.toArray(new LocalDate[0]); @@ -1468,6 +1714,25 @@ public LocalDate[] nonBusinessDates(final Instant start, final Instant end) { // region Differences + private long diffBusinessNanosInternal(final Instant start, final LocalDate startDate, final Instant end, + final LocalDate endDate) { + + if (startDate.equals(endDate)) { + final CalendarDay schedule = this.calendarDay(startDate); + return schedule.businessNanosElapsed(end) - schedule.businessNanosElapsed(start); + } + + long rst = this.calendarDay(startDate).businessNanosRemaining(start) + + this.calendarDay(endDate).businessNanosElapsed(end); + + for (LocalDate d = startDate.plusDays(1); d.isBefore(endDate); d = d.plusDays(1)) { + rst += this.calendarDay(d).businessNanos(); + } + + return rst; + } + + /** * Returns the amount of business time in nanoseconds between two times. * @@ -1492,19 +1757,28 @@ public long diffBusinessNanos(final Instant start, final Instant end) { assert startDate != null; assert endDate != null; - if (startDate.equals(endDate)) { - final CalendarDay schedule = this.calendarDay(startDate); - return schedule.businessNanosElapsed(end) - schedule.businessNanosElapsed(start); - } + SummaryData summaryFirst = null; + SummaryData summary = null; + long nanos = 0; - long rst = this.calendarDay(startDate).businessNanosRemaining(start) - + this.calendarDay(endDate).businessNanosElapsed(end); + for (Iterator it = summaryCache.iterator(startDate, endDate, false, false); it.hasNext();) { + summary = it.next(); - for (LocalDate d = startDate.plusDays(1); d.isBefore(endDate); d = d.plusDays(1)) { - rst += this.calendarDay(d).businessNanos(); + if (summaryFirst == null) { + summaryFirst = summary; + } + + nanos += summary.businessTimeNanos; } - return rst; + if (summaryFirst == null) { + return diffBusinessNanosInternal(start, startDate, end, endDate); + } else { + nanos += diffBusinessNanosInternal(start, startDate, summaryFirst.startInstant, summaryFirst.startDate); + nanos += diffBusinessNanosInternal(summary.endInstant, summary.endDate, end, endDate); + } + + return nanos; } /** @@ -1678,14 +1952,14 @@ public double diffBusinessYears(final Instant start, final Instant end) { final int yearEnd = DateTimeUtils.year(end, timeZone()); if (yearStart == yearEnd) { - return (double) diffBusinessNanos(start, end) / (double) getYearData(yearStart).businessTimeNanos; + return (double) diffBusinessNanos(start, end) / (double) getYearSummary(yearStart).businessTimeNanos; } - final YearData yearDataStart = getYearData(yearStart); - final YearData yearDataEnd = getYearData(yearEnd); + final SummaryData yearDataStart = getYearSummary(yearStart); + final SummaryData yearDataEnd = getYearSummary(yearEnd); - return (double) diffBusinessNanos(start, yearDataStart.end) / (double) yearDataStart.businessTimeNanos + - (double) diffBusinessNanos(yearDataEnd.start, end) / (double) yearDataEnd.businessTimeNanos + + return (double) diffBusinessNanos(start, yearDataStart.endInstant) / (double) yearDataStart.businessTimeNanos + + (double) diffBusinessNanos(yearDataEnd.startInstant, end) / (double) yearDataEnd.businessTimeNanos + yearEnd - yearStart - 1; } @@ -1764,7 +2038,7 @@ public String plusBusinessDays(final String date, final int days) { /** * Adds a specified number of business days to an input time. Adding negative days is equivalent to subtracting * days. - * + *

* Day additions are not always 24 hours. The resultant time will have the same local time as the input time, as * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which @@ -1788,12 +2062,12 @@ public Instant plusBusinessDays(final Instant time, final int days) { /** * Adds a specified number of business days to an input time. Adding negative days is equivalent to subtracting * days. - * + *

* Day additions are not always 24 hours. The resultant time will have the same local time as the input time, as * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which * is a 25-hour difference. - * + *

* The resultant time will have the same time zone as the calendar. This could be different than the time zone of * the input {@link ZonedDateTime}. * @@ -1856,7 +2130,7 @@ public String minusBusinessDays(final String date, final int days) { /** * Subtracts a specified number of business days from an input time. Subtracting negative days is equivalent to * adding days. - * + *

* Day subtractions are not always 24 hours. The resultant time will have the same local time as the input time, as * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which @@ -1879,12 +2153,12 @@ public Instant minusBusinessDays(final Instant time, final int days) { /** * Subtracts a specified number of business days from an input time. Subtracting negative days is equivalent to * adding days. - * + *

* Day subtraction are not always 24 hours. The resultant time will have the same local time as the input time, as * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which * is a 25-hour difference. - * + *

* The resultant time will have the same time zone as the calendar. This could be different than the time zone of * the input {@link ZonedDateTime}. * @@ -1956,12 +2230,12 @@ public String plusNonBusinessDays(final String date, final int days) { /** * Adds a specified number of non-business days to an input time. Adding negative days is equivalent to subtracting * days. - * + *

* Day additions are not always 24 hours. The resultant time will have the same local time as the input time, as * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which * is a 25-hour difference. - * + *

* The resultant time will have the same time zone as the calendar. This could be different than the time zone of * the input {@link ZonedDateTime}. * @@ -1983,12 +2257,12 @@ public Instant plusNonBusinessDays(final Instant time, final int days) { /** * Adds a specified number of non-business days to an input time. Adding negative days is equivalent to subtracting * days. - * + *

* Day additions are not always 24 hours. The resultant time will have the same local time as the input time, as * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which * is a 25-hour difference. - * + *

* The resultant time will have the same time zone as the calendar. This could be different than the time zone of * the input {@link ZonedDateTime}. * @@ -2051,7 +2325,7 @@ public String minusNonBusinessDays(final String date, final int days) { /** * Subtracts a specified number of non-business days to an input time. Subtracting negative days is equivalent to * adding days. - * + *

* Day subtractions are not always 24 hours. The resultant time will have the same local time as the input time, as * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which @@ -2074,12 +2348,12 @@ public Instant minusNonBusinessDays(final Instant time, final int days) { /** * Subtracts a specified number of non-business days to an input time. Subtracting negative days is equivalent to * adding days. - * + *

* Day subtractions are not always 24 hours. The resultant time will have the same local time as the input time, as * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which * is a 25-hour difference. - * + *

* The resultant time will have the same time zone as the calendar. This could be different than the time zone of * the input {@link ZonedDateTime}. * diff --git a/engine/time/src/main/java/io/deephaven/time/calendar/Calendar.java b/engine/time/src/main/java/io/deephaven/time/calendar/Calendar.java index 8b7946eaed8..9cb997112c6 100644 --- a/engine/time/src/main/java/io/deephaven/time/calendar/Calendar.java +++ b/engine/time/src/main/java/io/deephaven/time/calendar/Calendar.java @@ -9,9 +9,7 @@ import java.time.*; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import java.util.*; import static io.deephaven.util.QueryConstants.NULL_INT; @@ -30,6 +28,68 @@ public class Calendar { private final String description; private final ZoneId timeZone; + // region Cache + + private static class SummaryData extends ReadOptimizedConcurrentCache.IntKeyedValue { + final LocalDate startDate; + final LocalDate endDate; // exclusive + final List dates; + + SummaryData(int key, LocalDate startDate, LocalDate endDate, List dates) { + super(key); + this.startDate = startDate; + this.endDate = endDate; + this.dates = dates; + } + + } + + private final YearMonthSummaryCache summaryCache = + new YearMonthSummaryCache<>(this::computeMonthSummary, this::computeYearSummary); + + private SummaryData summarize(final int key, final LocalDate startDate, final LocalDate endDate) { + LocalDate date = startDate; + final ArrayList dates = new ArrayList<>(); + + while (date.isBefore(endDate)) { + dates.add(date); + date = date.plusDays(1); + } + + return new SummaryData(key, startDate, endDate, dates); // end date is exclusive + } + + private SummaryData computeMonthSummary(final int yearMonth) { + final int year = YearMonthSummaryCache.yearFromYearMonthKey(yearMonth); + final int month = YearMonthSummaryCache.monthFromYearMonthKey(yearMonth); + final LocalDate startDate = LocalDate.of(year, month, 1); + final LocalDate endDate = startDate.plusMonths(1); // exclusive + return summarize(yearMonth, startDate, endDate); + } + + private SummaryData computeYearSummary(final int year) { + LocalDate startDate = null; + LocalDate endDate = null; + ArrayList dates = new ArrayList<>(); + + for (int month = 1; month <= 12; month++) { + SummaryData ms = summaryCache.getMonthSummary(year, month); + if (month == 1) { + startDate = ms.startDate; + } + + if (month == 12) { + endDate = ms.endDate; + } + + dates.addAll(ms.dates); + } + + return new SummaryData(year, startDate, endDate, dates); + } + + // endregion + // region Constructors /** @@ -48,6 +108,17 @@ public class Calendar { // endregion + // region Cache + + /** + * Clears the cache. This should not generally be used and is provided for benchmarking. + */ + synchronized void clearCache() { + summaryCache.clear(); + } + + // endregion + // region Getters /** @@ -296,7 +367,7 @@ public Instant plusDays(final Instant time, final int days) { * determined by the calendar's time zone. This accounts for Daylight Savings Time. For example, 2023-11-05 has a * daylight savings time adjustment, so '2023-11-04T14:00 ET' plus 1 day will result in '2023-11-05T15:00 ET', which * is a 25-hour difference. - * + *

* The resultant time will have the same time zone as the calendar. This could be different than the time zone of * the input {@link ZonedDateTime}. * @@ -422,6 +493,18 @@ public LocalDate pastDate(final int days) { // region Ranges + private void calendarDatesInternal(final ArrayList result, final LocalDate start, final LocalDate end, + final boolean startInclusive, + final boolean endInclusive) { + for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { + final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + + if (!skip) { + result.add(day); + } + } + } + /** * Returns the dates in a given range. * @@ -437,14 +520,31 @@ public LocalDate[] calendarDates(final LocalDate start, final LocalDate end, fin return null; } - List dateList = new ArrayList<>(); + if (start.isAfter(end)) { + return new LocalDate[0]; + } - for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { - final boolean skip = (!startInclusive && day.equals(start)) || (!endInclusive && day.equals(end)); + final ArrayList dateList = new ArrayList<>(); - if (!skip) { - dateList.add(day); + SummaryData summaryFirst = null; + SummaryData summary = null; + + for (Iterator it = summaryCache.iterator(start, end, startInclusive, endInclusive); it + .hasNext();) { + summary = it.next(); + + if (summaryFirst == null) { + summaryFirst = summary; + calendarDatesInternal(dateList, start, summaryFirst.startDate, startInclusive, false); } + + dateList.addAll(summary.dates); + } + + if (summaryFirst == null) { + calendarDatesInternal(dateList, start, end, startInclusive, endInclusive); + } else { + calendarDatesInternal(dateList, summary.endDate, end, true, endInclusive); } return dateList.toArray(new LocalDate[0]); diff --git a/engine/time/src/main/java/io/deephaven/time/calendar/CalendarDay.java b/engine/time/src/main/java/io/deephaven/time/calendar/CalendarDay.java index 92b066ee924..d1c6d81bddf 100644 --- a/engine/time/src/main/java/io/deephaven/time/calendar/CalendarDay.java +++ b/engine/time/src/main/java/io/deephaven/time/calendar/CalendarDay.java @@ -30,7 +30,8 @@ public class CalendarDay & Temporal> { */ public static final CalendarDay HOLIDAY = new CalendarDay<>(); - private final List> businessTimeRanges; + private final TimeRange[] businessTimeRanges; + private final long businessNanos; /** * Creates a CalendarDay instance. @@ -63,7 +64,8 @@ public class CalendarDay & Temporal> { } } - this.businessTimeRanges = List.of(ranges); + this.businessTimeRanges = ranges; + this.businessNanos = Arrays.stream(businessTimeRanges).mapToLong(TimeRange::nanos).sum(); } /** @@ -80,7 +82,7 @@ public class CalendarDay & Temporal> { * @return business time ranges for the day */ public List> businessTimeRanges() { - return businessTimeRanges; + return Arrays.asList(businessTimeRanges); } /** @@ -89,7 +91,7 @@ public List> businessTimeRanges() { * @return start of the business day, or {@code null} for a holiday schedule */ public T businessStart() { - return !businessTimeRanges.isEmpty() ? businessTimeRanges.get(0).start() : null; + return businessTimeRanges.length > 0 ? businessTimeRanges[0].start() : null; } /** @@ -98,7 +100,7 @@ public T businessStart() { * @return end of the business day, or {@code null} for a holiday schedule */ public T businessEnd() { - return !businessTimeRanges.isEmpty() ? businessTimeRanges.get(businessTimeRanges.size() - 1).end() : null; + return businessTimeRanges.length > 0 ? businessTimeRanges[businessTimeRanges.length - 1].end() : null; } /** @@ -107,7 +109,7 @@ public T businessEnd() { * @return is the end of the business day inclusive? */ public boolean isInclusiveEnd() { - return businessTimeRanges.isEmpty() || businessTimeRanges.get(businessTimeRanges.size() - 1).isInclusiveEnd(); + return businessTimeRanges.length == 0 || businessTimeRanges[businessTimeRanges.length - 1].isInclusiveEnd(); } /** @@ -117,7 +119,7 @@ public boolean isInclusiveEnd() { * @return length of the day in nanoseconds */ public long businessNanos() { - return businessTimeRanges.stream().map(TimeRange::nanos).reduce(0L, Long::sum); + return businessNanos; } /** @@ -239,18 +241,18 @@ public boolean equals(Object o) { if (!(o instanceof CalendarDay)) return false; CalendarDay that = (CalendarDay) o; - return Objects.equals(businessTimeRanges, that.businessTimeRanges); + return Arrays.equals(businessTimeRanges, that.businessTimeRanges); } @Override public int hashCode() { - return Objects.hash(businessTimeRanges); + return Arrays.hashCode(businessTimeRanges); } @Override public String toString() { return "CalendarDay{" + - "businessTimeRanges=" + Arrays.toString(businessTimeRanges.toArray()) + + "businessTimeRanges=" + Arrays.toString(businessTimeRanges) + '}'; } diff --git a/engine/time/src/main/java/io/deephaven/time/calendar/ReadOptimizedConcurrentCache.java b/engine/time/src/main/java/io/deephaven/time/calendar/ReadOptimizedConcurrentCache.java new file mode 100644 index 00000000000..1c5de8bce04 --- /dev/null +++ b/engine/time/src/main/java/io/deephaven/time/calendar/ReadOptimizedConcurrentCache.java @@ -0,0 +1,126 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.time.calendar; + +import io.deephaven.hash.KeyedIntObjectHash; +import io.deephaven.hash.KeyedIntObjectKey; + +import java.util.function.IntFunction; + +/** + * A cache that is designed to be fast when accessed concurrently with read-heavy workloads. Values are populated from a + * function when they are not found in the cache. All values must be non-null. + * + * @param the value type + */ +class ReadOptimizedConcurrentCache { + + /** + * A value that has an included integer key. + */ + public static abstract class IntKeyedValue { + private final int key; + + /** + * Creates a new value. + * + * @param key the key + */ + public IntKeyedValue(int key) { + this.key = key; + } + + /** + * Gets the key. + * + * @return the key + */ + public int getKey() { + return key; + } + } + + /** + * A pair of a key and a value. + * + * @param the value type + */ + public static class Pair extends IntKeyedValue { + private final T value; + + /** + * Creates a new pair. + * + * @param key the key + * @param value the value + */ + public Pair(int key, T value) { + super(key); + this.value = value; + } + + /** + * Gets the value. + * + * @return the value + */ + public T getValue() { + return value; + } + } + + private static class KeyDef extends KeyedIntObjectKey.BasicStrict { + + @Override + public int getIntKey(V v) { + return v.getKey(); + } + + } + + private final IntFunction valueComputer; + private final KeyedIntObjectHash cache; + + /** + * Creates a new cache. + * + * @param initialCapacity the initial capacity + * @param valueComputer computes the value for a key. + */ + public ReadOptimizedConcurrentCache(final int initialCapacity, final IntFunction valueComputer) { + this.valueComputer = valueComputer; + this.cache = new KeyedIntObjectHash<>(initialCapacity, new KeyDef<>()); + } + + /** + * Clears the cache. + */ + synchronized void clear() { + cache.clear(); + } + + /** + * Gets the value for a key. If the value is not found, it is computed and added to the cache. + * + * @param key the key + * @return the value + * @throws NullPointerException if the value is not found + */ + public V computeIfAbsent(int key) { + V existing = cache.get(key); + + if (existing != null) { + return existing; + } + + final V newValue = valueComputer.apply(key); + + if (newValue == null) { + throw new NullPointerException("Computed a null value: key=" + key); + } + + cache.put(key, newValue); + return newValue; + } +} diff --git a/engine/time/src/main/java/io/deephaven/time/calendar/YearMonthSummaryCache.java b/engine/time/src/main/java/io/deephaven/time/calendar/YearMonthSummaryCache.java new file mode 100644 index 00000000000..cc183aad686 --- /dev/null +++ b/engine/time/src/main/java/io/deephaven/time/calendar/YearMonthSummaryCache.java @@ -0,0 +1,189 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.time.calendar; + +import java.time.LocalDate; +import java.util.Iterator; +import java.util.function.IntFunction; + +/** + * A thread-safe lazily initialized cache for year and month summaries. + * + * @param the type of the summary + */ +class YearMonthSummaryCache { + + /** + * Computes a year-month key for a year and month. + * + * @param year the year + * @param month the month + * @return the key + */ + static int yearMonthKey(int year, int month) { + return year * 100 + month; + } + + /** + * Gets the year from a year-month key. + * + * @param key the year month key + * @return the year + */ + static int yearFromYearMonthKey(int key) { + return key / 100; + } + + /** + * Gets the month from a year-month key. + * + * @param key the year month key + * @return the month + */ + static int monthFromYearMonthKey(int key) { + return key % 100; + } + + private final ReadOptimizedConcurrentCache monthCache; + private final ReadOptimizedConcurrentCache yearCache; + + /** + * Creates a new cache. + * + * @param computeMonthSummary the function to compute a month summary + * @param computeYearSummary the function to compute a year summary + */ + YearMonthSummaryCache(IntFunction computeMonthSummary, IntFunction computeYearSummary) { + monthCache = new ReadOptimizedConcurrentCache<>(12 * 50, computeMonthSummary); + yearCache = new ReadOptimizedConcurrentCache<>(50, computeYearSummary); + } + + /** + * Clears the cache. + */ + synchronized void clear() { + monthCache.clear(); + yearCache.clear(); + } + + /** + * Gets the month summary for the specified year and month. + * + * @param year the year + * @param month the month + * @return the month summary + */ + T getMonthSummary(int year, int month) { + return monthCache.computeIfAbsent(yearMonthKey(year, month)); + } + + /** + * Gets the year summary for the specified year. + * + * @param year the year + * @return the year summary + */ + T getYearSummary(int year) { + return yearCache.computeIfAbsent(year); + } + + private class YearMonthSummaryIterator implements Iterator { + + private int currentYear; + private int currentMonth; + private int currentYearMonth; + private int finalYear; + private int finalMonth; + final private int finalYearMonth; + + YearMonthSummaryIterator(LocalDate start, LocalDate end) { + int startYear = start.getYear(); + int startMonth = start.getMonthValue(); + int endYear = end.getYear(); + int endMonth = end.getMonthValue(); + + currentMonth = startMonth; + currentYear = startYear; + + if (start.getDayOfMonth() != 1) { + incrementCurrentByMonth(); + } + + currentYearMonth = yearMonthKey(currentYear, currentMonth); + + final LocalDate endPlus1 = end.plusDays(1); + final int endPlus1Month = endPlus1.getMonthValue(); + + finalMonth = endMonth; + finalYear = endYear; + + if (endPlus1Month == endMonth) { + if (finalMonth == 1) { + finalMonth = 12; + finalYear = finalYear - 1; + } else { + finalMonth = finalMonth - 1; + } + } + + finalYearMonth = yearMonthKey(finalYear, finalMonth); + } + + private void incrementCurrentByMonth() { + if (currentMonth == 12) { + currentMonth = 1; + currentYear += 1; + } else { + currentMonth = currentMonth + 1; + } + + currentYearMonth = yearMonthKey(currentYear, currentMonth); + } + + private void incrementCurrentByYear() { + currentYear++; + currentYearMonth = yearMonthKey(currentYear, currentMonth); + } + + @Override + public boolean hasNext() { + return currentYearMonth <= finalYearMonth; + } + + @Override + public T next() { + final T val; + + if (currentMonth == 1 && (currentYear != finalYear || finalMonth == 12)) { + val = getYearSummary(currentYear); + incrementCurrentByYear(); + } else { + val = getMonthSummary(currentYear, currentMonth); + incrementCurrentByMonth(); + } + + return val; + } + } + + /** + * Gets an iterator over the summaries for the specified range. The returned iterator will include the start date if + * {@code startInclusive} is true, and the end date if {@code endInclusive} is true. If the start date is after the + * end date, the iterator will be empty. + *

+ * The iterator will return summaries in chronological order, and these summaries can be a mix of month and year + * summaries. Dates not represented by complete summaries will be skipped (e.g. partial months). + * + * @param start the start date + * @param end the end date + * @param startInclusive whether the start date is inclusive + * @param endInclusive whether the end date is inclusive + * @return the iterator + */ + Iterator iterator(final LocalDate start, final LocalDate end, + final boolean startInclusive, final boolean endInclusive) { + return new YearMonthSummaryIterator(startInclusive ? start : start.plusDays(1), + endInclusive ? end : end.minusDays(1)); + } +} diff --git a/engine/time/src/test/java/io/deephaven/time/calendar/TestBusinessCalendar.java b/engine/time/src/test/java/io/deephaven/time/calendar/TestBusinessCalendar.java index cce9c4b07a8..34e5873d446 100644 --- a/engine/time/src/test/java/io/deephaven/time/calendar/TestBusinessCalendar.java +++ b/engine/time/src/test/java/io/deephaven/time/calendar/TestBusinessCalendar.java @@ -7,10 +7,7 @@ import java.time.*; import java.time.temporal.ChronoUnit; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; +import java.util.*; import static io.deephaven.util.QueryConstants.*; @@ -46,6 +43,17 @@ protected void setUp() throws Exception { calendar = bCalendar; } + public void testSchedulesCacheKeys() { + final int y = 2023; + final int m = 7; + final int d = 11; + final LocalDate ld = LocalDate.of(y, m, d); + + final int key = BusinessCalendar.schedulesCacheKeyFromDate(ld); + assertEquals(key, y * 10000 + m * 100 + d); + assertEquals(ld, BusinessCalendar.schedulesCacheDateFromKey(key)); + } + public void testBusinessGetters() { assertEquals(schedule, bCalendar.standardBusinessDay()); assertEquals(schedule.businessNanos(), bCalendar.standardBusinessNanos()); @@ -692,6 +700,22 @@ public void testBusinessDates() { assertNull(bCalendar.businessDates(start.atTime(1, 2).atZone(timeZone).toInstant(), null, true, true)); assertNull(bCalendar.businessDates(null, end.atTime(1, 2).atZone(timeZone), true, true)); assertNull(bCalendar.businessDates(start.atTime(1, 2).atZone(timeZone), null, true, true)); + + // end before start + assertEquals(new LocalDate[0], bCalendar.businessDates(end, start)); + + // long span of dates + final LocalDate startLong = LocalDate.of(2019, 2, 1); + final LocalDate endLong = LocalDate.of(2023, 12, 31); + final ArrayList targetLong = new ArrayList<>(); + + for (LocalDate d = startLong; !d.isAfter(endLong); d = d.plusDays(1)) { + if (bCalendar.isBusinessDay(d)) { + targetLong.add(d); + } + } + + assertEquals(targetLong.toArray(LocalDate[]::new), bCalendar.businessDates(startLong, endLong)); } public void testNumberBusinessDates() { @@ -758,6 +782,160 @@ public void testNumberBusinessDates() { bCalendar.numberBusinessDates(start.atTime(1, 2).atZone(timeZone).toInstant(), null, true, true)); assertEquals(NULL_INT, bCalendar.numberBusinessDates(null, end.atTime(1, 2).atZone(timeZone), true, true)); assertEquals(NULL_INT, bCalendar.numberBusinessDates(start.atTime(1, 2).atZone(timeZone), null, true, true)); + + // end before start + assertEquals(0, bCalendar.numberBusinessDates(end, start)); + + // long span of dates + final LocalDate startLong = LocalDate.of(2019, 2, 1); + final LocalDate endLong = LocalDate.of(2023, 12, 31); + final ArrayList targetLong = new ArrayList<>(); + + for (LocalDate d = startLong; !d.isAfter(endLong); d = d.plusDays(1)) { + if (bCalendar.isBusinessDay(d)) { + targetLong.add(d); + } + } + + assertEquals(targetLong.size(), bCalendar.numberBusinessDates(startLong, endLong)); + } + + public void testBusinessDatesValidateCacheIteration() { + // Construct a very simple calendar for counting business days that is easy to reason about + + final LocalDate firstValidDateLocal = LocalDate.of(2000, 1, 1); + final LocalDate lastValidDateLocal = LocalDate.of(2030, 12, 31); + final Set weekendDaysLocal = Set.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY); + final Map> holidaysLocal = new HashMap<>(); + final BusinessCalendar bc = new BusinessCalendar("Test", "Test", timeZone, firstValidDateLocal, + lastValidDateLocal, schedule, weekendDaysLocal, holidaysLocal); + + // short period -- no caching case + + LocalDate start = LocalDate.of(2024, 5, 15); + LocalDate end = LocalDate.of(2024, 5, 29); + + LocalDate[] target = { + LocalDate.of(2024, 5, 15), + LocalDate.of(2024, 5, 16), + LocalDate.of(2024, 5, 17), + LocalDate.of(2024, 5, 20), + LocalDate.of(2024, 5, 21), + LocalDate.of(2024, 5, 22), + LocalDate.of(2024, 5, 23), + LocalDate.of(2024, 5, 24), + LocalDate.of(2024, 5, 27), + LocalDate.of(2024, 5, 28), + LocalDate.of(2024, 5, 29), + }; + + assertEquals(target, bc.businessDates(start, end, true, true)); + assertEquals(target.length, bc.numberBusinessDates(start, end, true, true)); + + target = new LocalDate[] { + LocalDate.of(2024, 5, 16), + LocalDate.of(2024, 5, 17), + LocalDate.of(2024, 5, 20), + LocalDate.of(2024, 5, 21), + LocalDate.of(2024, 5, 22), + LocalDate.of(2024, 5, 23), + LocalDate.of(2024, 5, 24), + LocalDate.of(2024, 5, 27), + LocalDate.of(2024, 5, 28), + }; + + assertEquals(target, bc.businessDates(start, end, false, false)); + assertEquals(target.length, bc.numberBusinessDates(start, end, false, false)); + + // long period -- caching case + + start = LocalDate.of(2024, 5, 15); + end = LocalDate.of(2025, 5, 29); + + final ArrayList targetList = new ArrayList<>(); + LocalDate d = start; + + while (!d.isAfter(end)) { + if (!weekendDaysLocal.contains(d.getDayOfWeek())) { + targetList.add(d); + } + d = d.plusDays(1); + } + + target = targetList.toArray(new LocalDate[0]); + + assertEquals(target, bc.businessDates(start, end, true, true)); + assertEquals(target.length, bc.numberBusinessDates(start, end, true, true)); + + targetList.remove(0); + targetList.remove(targetList.size() - 1); + target = targetList.toArray(new LocalDate[0]); + + assertEquals(target, bc.businessDates(start, end, false, false)); + assertEquals(target.length, bc.numberBusinessDates(start, end, false, false)); + } + + public void testNonBusinessDatesValidateCacheIteration() { + // Construct a very simple calendar for counting business days that is easy to reason about + + final LocalDate firstValidDateLocal = LocalDate.of(2000, 1, 1); + final LocalDate lastValidDateLocal = LocalDate.of(2030, 12, 31); + final Set weekendDaysLocal = Set.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY); + final Map> holidaysLocal = new HashMap<>(); + final BusinessCalendar bc = new BusinessCalendar("Test", "Test", timeZone, firstValidDateLocal, + lastValidDateLocal, schedule, weekendDaysLocal, holidaysLocal); + + // short period -- no caching case + + LocalDate start = LocalDate.of(2024, 5, 12); + LocalDate end = LocalDate.of(2024, 5, 26); + + LocalDate[] target = { + LocalDate.of(2024, 5, 12), + LocalDate.of(2024, 5, 18), + LocalDate.of(2024, 5, 19), + LocalDate.of(2024, 5, 25), + LocalDate.of(2024, 5, 26), + }; + + assertEquals(target, bc.nonBusinessDates(start, end, true, true)); + assertEquals(target.length, bc.numberNonBusinessDates(start, end, true, true)); + + target = new LocalDate[] { + LocalDate.of(2024, 5, 18), + LocalDate.of(2024, 5, 19), + LocalDate.of(2024, 5, 25), + }; + + assertEquals(target, bc.nonBusinessDates(start, end, false, false)); + assertEquals(target.length, bc.numberNonBusinessDates(start, end, false, false)); + + // long period -- caching case + + start = LocalDate.of(2024, 5, 19); + end = LocalDate.of(2025, 5, 17); + + final ArrayList targetList = new ArrayList<>(); + LocalDate d = start; + + while (!d.isAfter(end)) { + if (weekendDaysLocal.contains(d.getDayOfWeek())) { + targetList.add(d); + } + d = d.plusDays(1); + } + + target = targetList.toArray(new LocalDate[0]); + + assertEquals(target, bc.nonBusinessDates(start, end, true, true)); + assertEquals(target.length, bc.numberNonBusinessDates(start, end, true, true)); + + targetList.remove(0); + targetList.remove(targetList.size() - 1); + target = targetList.toArray(new LocalDate[0]); + + assertEquals(target, bc.nonBusinessDates(start, end, false, false)); + assertEquals(target.length, bc.numberNonBusinessDates(start, end, false, false)); } public void testNonBusinessDates() { @@ -840,6 +1018,22 @@ public void testNonBusinessDates() { assertNull(bCalendar.nonBusinessDates(start.atTime(1, 2).atZone(timeZone).toInstant(), null, true, true)); assertNull(bCalendar.nonBusinessDates(null, end.atTime(1, 2).atZone(timeZone), true, true)); assertNull(bCalendar.nonBusinessDates(start.atTime(1, 2).atZone(timeZone), null, true, true)); + + // end before start + assertEquals(new LocalDate[0], bCalendar.nonBusinessDates(end, start)); + + // long span of dates + final LocalDate startLong = LocalDate.of(2019, 2, 1); + final LocalDate endLong = LocalDate.of(2023, 12, 31); + final ArrayList targetLong = new ArrayList<>(); + + for (LocalDate d = startLong; !d.isAfter(endLong); d = d.plusDays(1)) { + if (!bCalendar.isBusinessDay(d)) { + targetLong.add(d); + } + } + + assertEquals(targetLong.toArray(LocalDate[]::new), bCalendar.nonBusinessDates(startLong, endLong)); } public void testNumberNonBusinessDates() { @@ -867,6 +1061,7 @@ public void testNumberNonBusinessDates() { // LocalDate.of(2023,7,15), // }; + assertEquals(0, bCalendar.numberNonBusinessDates(end, start)); assertEquals(nonBus.length, bCalendar.numberNonBusinessDates(start, end)); assertEquals(nonBus.length, bCalendar.numberNonBusinessDates(start.toString(), end.toString())); assertEquals(nonBus.length, bCalendar.numberNonBusinessDates(start.atTime(1, 24).atZone(timeZone), @@ -914,6 +1109,11 @@ public void testNumberNonBusinessDates() { bCalendar.numberNonBusinessDates(start.atTime(1, 2).atZone(timeZone).toInstant(), null, true, true)); assertEquals(NULL_INT, bCalendar.numberNonBusinessDates(null, end.atTime(1, 2).atZone(timeZone), true, true)); assertEquals(NULL_INT, bCalendar.numberNonBusinessDates(start.atTime(1, 2).atZone(timeZone), null, true, true)); + + final LocalDate startLong = LocalDate.of(2023, 7, 3); + final LocalDate endLong = LocalDate.of(2025, 7, 15); + final LocalDate[] nonBusLong = bCalendar.nonBusinessDates(startLong, endLong); + assertEquals(nonBusLong.length, bCalendar.numberNonBusinessDates(startLong, endLong)); } public void testDiffBusinessNanos() { @@ -1547,4 +1747,25 @@ public void testPastNonBusinessDate() { assertNull(bCalendar.pastNonBusinessDate(NULL_INT)); } + public void testClearCache() { + final LocalDate start = LocalDate.of(2023, 7, 3); + final LocalDate end = LocalDate.of(2025, 7, 10); + + final CalendarDay v1 = bCalendar.calendarDay(); + final CalendarDay v2 = bCalendar.calendarDay(); + assertEquals(v1, v2); + final int i1 = bCalendar.numberBusinessDates(start, end); + final int i2 = bCalendar.numberBusinessDates(start, end); + assertEquals(i1, i2); + + bCalendar.clearCache(); + + final CalendarDay v3 = bCalendar.calendarDay(); + final CalendarDay v4 = bCalendar.calendarDay(); + assertEquals(v3, v4); + final int i3 = bCalendar.numberBusinessDates(start, end); + final int i4 = bCalendar.numberBusinessDates(start, end); + assertEquals(i3, i4); + } + } diff --git a/engine/time/src/test/java/io/deephaven/time/calendar/TestCalendar.java b/engine/time/src/test/java/io/deephaven/time/calendar/TestCalendar.java index 1a6936e3832..8909d2232c1 100644 --- a/engine/time/src/test/java/io/deephaven/time/calendar/TestCalendar.java +++ b/engine/time/src/test/java/io/deephaven/time/calendar/TestCalendar.java @@ -7,6 +7,7 @@ import io.deephaven.time.DateTimeUtils; import java.time.*; +import java.util.ArrayList; import static io.deephaven.util.QueryConstants.NULL_INT; @@ -224,6 +225,20 @@ public void testCalendarDates() { assertNull(calendar.calendarDates(start.atTime(3, 15).atZone(timeZone).toInstant(), null, false, false)); assertNull(calendar.calendarDates(null, end.atTime(3, 15).atZone(timeZone), false, false)); assertNull(calendar.calendarDates(start.atTime(3, 15).atZone(timeZone), null, false, false)); + + // end before start + assertEquals(new LocalDate[0], calendar.calendarDates(end, start)); + + // long span of dates + final LocalDate startLong = LocalDate.of(2019, 2, 1); + final LocalDate endLong = LocalDate.of(2023, 12, 31); + final ArrayList targetLong = new ArrayList<>(); + + for (LocalDate d = startLong; !d.isAfter(endLong); d = d.plusDays(1)) { + targetLong.add(d); + } + + assertEquals(targetLong.toArray(LocalDate[]::new), calendar.calendarDates(startLong, endLong)); } public void testNumberCalendarDates() { diff --git a/engine/time/src/test/java/io/deephaven/time/calendar/TestCalendarDay.java b/engine/time/src/test/java/io/deephaven/time/calendar/TestCalendarDay.java index 93f2dd5520e..fded3f8adc6 100644 --- a/engine/time/src/test/java/io/deephaven/time/calendar/TestCalendarDay.java +++ b/engine/time/src/test/java/io/deephaven/time/calendar/TestCalendarDay.java @@ -7,8 +7,8 @@ import io.deephaven.time.DateTimeUtils; import java.time.*; +import java.util.Arrays; import java.util.List; -import java.util.Objects; import static io.deephaven.util.QueryConstants.NULL_LONG; import static org.junit.Assert.assertNotEquals; @@ -216,7 +216,7 @@ public void testEqualsHash() { final CalendarDay multi = new CalendarDay<>(new TimeRange[] {period1, period2}); assertEquals(List.of(period1, period2), multi.businessTimeRanges()); - int hashTarget = Objects.hash(multi.businessTimeRanges()); + int hashTarget = Arrays.hashCode(multi.businessTimeRanges().toArray()); assertEquals(hashTarget, multi.hashCode()); final CalendarDay multi2 = new CalendarDay<>(new TimeRange[] {period1, period2}); diff --git a/engine/time/src/test/java/io/deephaven/time/calendar/TestReadOptimizedConcurrentCache.java b/engine/time/src/test/java/io/deephaven/time/calendar/TestReadOptimizedConcurrentCache.java new file mode 100644 index 00000000000..20bca069d3e --- /dev/null +++ b/engine/time/src/test/java/io/deephaven/time/calendar/TestReadOptimizedConcurrentCache.java @@ -0,0 +1,56 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.time.calendar; + +import io.deephaven.base.testing.BaseArrayTestCase; + +public class TestReadOptimizedConcurrentCache extends BaseArrayTestCase { + + private static class Value extends ReadOptimizedConcurrentCache.Pair { + Value(int key, String value) { + super(key, value); + } + } + + private static Value makeVal(Integer key) { + final Character c = (char) (key + 'a'); + + if (c.equals('d')) { + return null; + } + + return new Value(key, c.toString().toUpperCase()); + } + + public void testCache() { + final ReadOptimizedConcurrentCache cache = + new ReadOptimizedConcurrentCache<>(10, TestReadOptimizedConcurrentCache::makeVal); + + assertEquals("A", cache.computeIfAbsent(0).getValue()); + assertEquals("A", cache.computeIfAbsent(0).getValue()); + + assertEquals("A", cache.computeIfAbsent(0).getValue()); + assertEquals("B", cache.computeIfAbsent(1).getValue()); + assertEquals("C", cache.computeIfAbsent(2).getValue()); + + try { + cache.computeIfAbsent(3); + fail("Expected exception"); + } catch (final NullPointerException e) { + // pass + } + + cache.clear(); + assertEquals("A", cache.computeIfAbsent(0).getValue()); + assertEquals("B", cache.computeIfAbsent(1).getValue()); + assertEquals("C", cache.computeIfAbsent(2).getValue()); + + try { + cache.computeIfAbsent(3); + fail("Expected exception"); + } catch (final NullPointerException e) { + // pass + } + } +} diff --git a/engine/time/src/test/java/io/deephaven/time/calendar/TestStaticCalendarMethods.java b/engine/time/src/test/java/io/deephaven/time/calendar/TestStaticCalendarMethods.java index 47f6ed9ef5a..6d2936214ce 100644 --- a/engine/time/src/test/java/io/deephaven/time/calendar/TestStaticCalendarMethods.java +++ b/engine/time/src/test/java/io/deephaven/time/calendar/TestStaticCalendarMethods.java @@ -107,6 +107,7 @@ public void testAll() { excludes.add("description"); excludes.add("firstValidDate"); excludes.add("lastValidDate"); + excludes.add("clearCache"); // Occasionally tests fail because of invalid clocks on the test system final LocalDate startDate = LocalDate.of(1990, 1, 1); diff --git a/engine/time/src/test/java/io/deephaven/time/calendar/TestYearMonthSummaryCache.java b/engine/time/src/test/java/io/deephaven/time/calendar/TestYearMonthSummaryCache.java new file mode 100644 index 00000000000..7d8568b1f56 --- /dev/null +++ b/engine/time/src/test/java/io/deephaven/time/calendar/TestYearMonthSummaryCache.java @@ -0,0 +1,333 @@ +// +// Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending +// +package io.deephaven.time.calendar; + +import io.deephaven.base.testing.BaseArrayTestCase; + +import java.time.LocalDate; +import java.util.Iterator; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.IntFunction; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +@SuppressWarnings({"DataFlowIssue", "ConstantValue"}) +public class TestYearMonthSummaryCache extends BaseArrayTestCase { + + private static class Value extends ReadOptimizedConcurrentCache.Pair { + Value(int key, String value) { + super(key, value); + } + } + + public void testKeys() { + final int y = 2021; + final int m = 3; + final int key = YearMonthSummaryCache.yearMonthKey(y, m); + assertEquals(key, 2021 * 100 + m); + assertEquals(y, YearMonthSummaryCache.yearFromYearMonthKey(key)); + assertEquals(m, YearMonthSummaryCache.monthFromYearMonthKey(key)); + } + + public void testGetters() { + final int[] monthCount = new int[] {0}; + final int[] yearCount = new int[] {0}; + + final IntFunction monthSummary = i -> { + monthCount[0]++; + return new Value(i, "month" + i); + }; + + final IntFunction yearSummary = i -> { + yearCount[0]++; + return new Value(i, "year" + i); + }; + + final YearMonthSummaryCache cache = new YearMonthSummaryCache<>(monthSummary, yearSummary); + + cache.clear(); + monthCount[0] = 0; + yearCount[0] = 0; + + assertEquals("month202101", cache.getMonthSummary(2021, 1).getValue()); + assertEquals(1, monthCount[0]); + assertEquals(0, yearCount[0]); + assertEquals("year2021", cache.getYearSummary(2021).getValue()); + assertEquals(1, monthCount[0]); + assertEquals(1, yearCount[0]); + assertEquals("month202101", cache.getMonthSummary(2021, 1).getValue()); + assertEquals(1, monthCount[0]); + assertEquals(1, yearCount[0]); + assertEquals("year2021", cache.getYearSummary(2021).getValue()); + assertEquals(1, monthCount[0]); + assertEquals(1, yearCount[0]); + + assertEquals("month202102", cache.getMonthSummary(2021, 2).getValue()); + assertEquals(2, monthCount[0]); + assertEquals(1, yearCount[0]); + assertEquals("year2022", cache.getYearSummary(2022).getValue()); + assertEquals(2, monthCount[0]); + assertEquals(2, yearCount[0]); + + cache.clear(); + + assertEquals("month202101", cache.getMonthSummary(2021, 1).getValue()); + assertEquals(3, monthCount[0]); + assertEquals(2, yearCount[0]); + assertEquals("year2021", cache.getYearSummary(2021).getValue()); + assertEquals(3, monthCount[0]); + assertEquals(3, yearCount[0]); + assertEquals("month202101", cache.getMonthSummary(2021, 1).getValue()); + assertEquals(3, monthCount[0]); + assertEquals(3, yearCount[0]); + assertEquals("year2021", cache.getYearSummary(2021).getValue()); + assertEquals(3, monthCount[0]); + assertEquals(3, yearCount[0]); + + assertEquals("month202102", cache.getMonthSummary(2021, 2).getValue()); + assertEquals(4, monthCount[0]); + assertEquals(3, yearCount[0]); + assertEquals("year2022", cache.getYearSummary(2022).getValue()); + assertEquals(4, monthCount[0]); + assertEquals(4, yearCount[0]); + } + + private static Stream iteratorToStream(Iterator iterator) { + Spliterator spliterator = Spliterators.spliteratorUnknownSize(iterator, 0); + return StreamSupport.stream(spliterator, false); + } + + public void testIteratorInclusive() { + final YearMonthSummaryCache cache = + new YearMonthSummaryCache<>(i -> new Value(i, "month" + i), i -> new Value(i, "year" + i)); + final boolean startInclusive = true; + final boolean endInclusive = true; + + // end before start + LocalDate start = LocalDate.of(2021, 1, 2); + LocalDate end = LocalDate.of(2021, 1, 1); + String[] target = {}; + String[] actual = + iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).toArray(String[]::new); + assertEquals(target, actual); + + // same month + start = LocalDate.of(2021, 1, 1); + end = LocalDate.of(2021, 1, 11); + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).toArray(String[]::new); + assertEquals(target, actual); + + // adjacent partial months + start = LocalDate.of(2021, 1, 3); + end = LocalDate.of(2021, 2, 11); + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).toArray(String[]::new); + assertEquals(target, actual); + + // full month + partial month + start = LocalDate.of(2021, 1, 1); + end = LocalDate.of(2021, 2, 11); + target = new String[] {"month202101"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // full month + few days + start = LocalDate.of(2020, 12, 12); + end = LocalDate.of(2021, 2, 11); + target = new String[] {"month202101"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // multiple months + few days + start = LocalDate.of(2020, 11, 12); + end = LocalDate.of(2021, 4, 11); + target = new String[] {"month202012", "month202101", "month202102", "month202103"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // partial month + full month + start = LocalDate.of(2021, 1, 3); + end = LocalDate.of(2021, 2, 28); + target = new String[] {"month202102"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // full year + start = LocalDate.of(2021, 1, 1); + end = LocalDate.of(2021, 12, 31); + target = new String[] {"year2021"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // full year + few days + start = LocalDate.of(2020, 12, 11); + end = LocalDate.of(2022, 1, 3); + target = new String[] {"year2021"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // multiple years + few days + start = LocalDate.of(2018, 12, 11); + end = LocalDate.of(2022, 1, 3); + target = new String[] {"year2019", "year2020", "year2021"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // mixed + start = LocalDate.of(2018, 10, 11); + end = LocalDate.of(2022, 3, 3); + target = new String[] {"month201811", "month201812", "year2019", "year2020", "year2021", "month202201", + "month202202"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + } + + public void testIteratorExclusiveInclusive() { + final YearMonthSummaryCache cache = + new YearMonthSummaryCache<>(i -> new Value(i, "month" + i), i -> new Value(i, "year" + i)); + + // start and end of month + + LocalDate start = LocalDate.of(2021, 12, 1); + LocalDate end = LocalDate.of(2021, 12, 31); + + boolean startInclusive = true; + boolean endInclusive = true; + String[] target = new String[] {"month202112"}; + String[] actual = + iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = false; + endInclusive = true; + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = true; + endInclusive = false; + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = false; + endInclusive = false; + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // day before start of month + + start = LocalDate.of(2021, 11, 30); + end = LocalDate.of(2021, 12, 31); + + startInclusive = true; + endInclusive = true; + target = new String[] {"month202112"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = false; + endInclusive = true; + target = new String[] {"month202112"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = true; + endInclusive = false; + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = false; + endInclusive = false; + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // day after end of month + + start = LocalDate.of(2021, 12, 1); + end = LocalDate.of(2022, 1, 1); + + startInclusive = true; + endInclusive = true; + target = new String[] {"month202112"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = false; + endInclusive = true; + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = true; + endInclusive = false; + target = new String[] {"month202112"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = false; + endInclusive = false; + target = new String[] {}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + // day before and after end of month + + start = LocalDate.of(2021, 11, 30); + end = LocalDate.of(2022, 1, 1); + + startInclusive = true; + endInclusive = true; + target = new String[] {"month202112"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = false; + endInclusive = true; + target = new String[] {"month202112"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = true; + endInclusive = false; + target = new String[] {"month202112"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + + startInclusive = false; + endInclusive = false; + target = new String[] {"month202112"}; + actual = iteratorToStream(cache.iterator(start, end, startInclusive, endInclusive)).map(x -> x.getValue()) + .toArray(String[]::new); + assertEquals(target, actual); + } +}