Skip to content

[manager] bugfix: fix incorrect status page date calculation#3962

Merged
tomsun28 merged 13 commits into
apache:masterfrom
BhanuNidumolu:fix-status-page-date
Jan 17, 2026
Merged

[manager] bugfix: fix incorrect status page date calculation#3962
tomsun28 merged 13 commits into
apache:masterfrom
BhanuNidumolu:fix-status-page-date

Conversation

@BhanuNidumolu

@BhanuNidumolu BhanuNidumolu commented Jan 12, 2026

Copy link
Copy Markdown
Contributor

What's changed?

This PR fixes incorrect date calculation on the Status Page (#3076).

Details

  • Fixed midnight race condition by using a single Instant for both now and todayStart
  • Replaced LocalDateTime + ZoneOffset logic with Instant + ZoneId
  • Ensured DST-safe day boundaries using calendar-based date arithmetic
  • Fixed timestamp sorting using Long.compare to avoid integer overflow

This prevents:

  • Negative or inverted time ranges at midnight
  • Incorrect day boundaries on DST (23h / 25h days)
  • Sorting bugs for long time ranges

Checklist

  • I have read the Contributing Guide
  • I have written the necessary comments
  • I have verified the date logic locally

Add or update API

  • Not applicable

@BhanuNidumolu

Copy link
Copy Markdown
Contributor Author

Hi maintainers 👋

This PR fixes the incorrect date calculation on the Status Page (#3076).
The logic now uses Instant + ZoneId based day boundaries instead of LocalDateTime + ZoneOffset, which prevents timezone and DST-related date shifts when aggregating daily status history.

All hertzbeat-manager tests pass locally.
Looking forward to your review — thanks!

@delei

delei commented Jan 12, 2026

Copy link
Copy Markdown
Member

Hi, @BhanuNidumolu Welcome.

This PR seems to handle different issues simultaneously. We recommend that one PR only fix one problem, which will be helpful for code review.

BTW, could you please provide some unit test codes or test screenshots?

@BhanuNidumolu

Copy link
Copy Markdown
Contributor Author

Thanks for the review and the suggestion!

I will split them into two separate PRs to make review easier:

• This PR (#3962) will only keep the Status Page date bugfix
• I will move the MySQL account expiry metrics into a new PR

Regarding testing:

  • The Status Page change is covered by existing StatusPageService tests (all passing locally)
  • For the MySQL template, I will provide SQL test results and screenshots in the new PR

I will update this PR shortly. Thanks for the guidance!

@BhanuNidumolu

BhanuNidumolu commented Jan 14, 2026

Copy link
Copy Markdown
Contributor Author

Verification – Midnight & DST Boundary

I reproduced the midnight boundary case by inserting two records into the HertzBeat H2 store (HZB_STATUS_PAGE_HISTORY):

  • 2026-01-13 23:59:59 → ABNORMAL
  • 2026-01-14 00:00:01 → NORMAL

This creates a real midnight-crossing outage.

With the new implementation:

  • A single Instant is used for now and todayStart
  • Day boundaries are computed via Instant + ZoneId (not minusSeconds)
  • Sorting uses Long.compare

Result:
Both sliding 24-hour windows correctly include the 300s outage and render consistent uptime on the Status Page (red + green bars).
No inverted ranges or DST errors occur.

SCREENSHOTS:

Screenshot 2026-01-14 215732 Screenshot 2026-01-14 215740

Clarification: Why two “2026-01-14” bars show different uptime

Although both bars in the screenshots are labeled 2026-01-14, they represent different rolling 24-hour windows, not the same calendar day.

For example:

2026-01-14 10:29 shows the window 2026-01-13 10:29 → 2026-01-14 10:29, which includes only the midnight outage and unknown time → 0% uptime

2026-01-14 21:53 shows the window 2026-01-13 21:53 → 2026-01-14 21:53, which includes the same outage plus many hours of recovery → 98.15% uptime

This confirms the new Instant + ZoneId based boundary logic is working correctly across midnight and DST, and that the same outage is consistently included in all overlapping 24-hour windows.

Screenshot 2026-01-15 085525 Screenshot 2026-01-15 085531

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes critical date calculation bugs in the Status Page service that could cause incorrect time ranges at midnight, incorrect day boundaries during DST transitions, and integer overflow in timestamp sorting.

Changes:

  • Replaced LocalDateTime with Instant and ZonedDateTime for proper timezone handling
  • Fixed midnight race conditions by capturing a single Instant for all date calculations
  • Corrected timestamp sorting to use Long.compare() instead of integer casting
  • Improved code readability with better variable naming and formatting

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +133 to 196
ZoneId zoneId = ZoneId.systemDefault();

Instant now = Instant.now();
long nowTimestamp = now.toEpochMilli();

long todayStartTimestamp = now
.atZone(zoneId)
.toLocalDate()
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli();

List<StatusPageHistory> todayStatusPageHistoryList = statusPageHistoryDao
.findStatusPageHistoriesByComponentIdAndTimestampBetween(component.getId(), todayStartTimestamp, nowTimestamp);
StatusPageHistory todayStatus = combineOneDayStatusPageHistory(todayStatusPageHistoryList, component, nowTimestamp);
histories.add(todayStatus);
// query 30d component status history
LocalDateTime preTime = todayStartTime.minusDays(HISTORY_SPAN_DAYS);
long preTimestamp = preTime.toInstant(zoneOffset).toEpochMilli();
long preTimestamp = now
.atZone(zoneId)
.toLocalDate()
.minusDays(HISTORY_SPAN_DAYS)
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli();


List<StatusPageHistory> history = statusPageHistoryDao
.findStatusPageHistoriesByComponentIdAndTimestampBetween(component.getId(), preTimestamp, todayStartTimestamp);
LinkedList<StatusPageHistory> historyList = new LinkedList<>(history);
historyList.sort((o1, o2) -> (int) (o1.getTimestamp() - o2.getTimestamp()));
LocalDateTime endTime = todayStartTime.minusSeconds(1);
LocalDateTime startTime = endTime.withHour(0).withMinute(0).withSecond(0).withNano(0);
for (int index = 0; index < HISTORY_SPAN_DAYS; index++) {
long startTimestamp = startTime.toInstant(zoneOffset).toEpochMilli();
long endTimestamp = endTime.toInstant(zoneOffset).toEpochMilli();
List<StatusPageHistory> thisDayHistory = historyList.stream().filter(item ->
item.getTimestamp() >= startTimestamp && item.getTimestamp() <= endTimestamp)
.collect(Collectors.toList());
historyList.sort((o1, o2) -> Long.compare(o1.getTimestamp(), o2.getTimestamp()));
ZonedDateTime end = Instant.ofEpochMilli(todayStartTimestamp)
.atZone(zoneId)
.minusSeconds(1); // yesterday 23:59:59 local time

for (int i = 0; i < HISTORY_SPAN_DAYS; i++) {
long endTimestamp = end.toInstant().toEpochMilli();

long startTimestamp = end.toLocalDate()
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli();

List<StatusPageHistory> thisDayHistory = historyList.stream()
.filter(h -> h.getTimestamp() >= startTimestamp && h.getTimestamp() <= endTimestamp)
.collect(Collectors.toList());

if (thisDayHistory.isEmpty()) {
StatusPageHistory statusPageHistory = StatusPageHistory.builder().timestamp(endTimestamp)
.componentId(component.getId()).state(CommonConstants.STATUS_PAGE_COMPONENT_STATE_UNKNOWN).build();
histories.add(statusPageHistory);
histories.add(StatusPageHistory.builder()
.timestamp(endTimestamp)
.componentId(component.getId())
.state(CommonConstants.STATUS_PAGE_COMPONENT_STATE_UNKNOWN)
.build());
} else if (thisDayHistory.size() == 1) {
histories.add(thisDayHistory.get(0));
} else {
StatusPageHistory statusPageHistory = combineOneDayStatusPageHistory(thisDayHistory, component, endTimestamp);
histories.add(statusPageHistory);
StatusPageHistory merged =
combineOneDayStatusPageHistory(thisDayHistory, component, endTimestamp);
histories.add(merged);
statusPageHistoryDao.deleteAll(thisDayHistory);
statusPageHistoryDao.save(statusPageHistory);
statusPageHistoryDao.save(merged);
}
startTime = startTime.minusDays(1);
endTime = endTime.minusDays(1);

end = end.minusDays(1);
}

Copilot AI Jan 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The date calculation logic in queryComponentsStatus has been significantly refactored to fix midnight race conditions and DST issues, but there are no unit tests covering this critical logic. Consider adding tests that verify the date calculations work correctly, especially for edge cases like midnight boundaries, DST transitions, and the 29-day history window calculation.

Copilot uses AI. Check for mistakes.
Comment on lines +250 to 322
ZoneId zoneId = ZoneId.systemDefault();

Instant now = Instant.now();
long nowTimestamp = now.toEpochMilli();

long todayStartTimestamp = now
.atZone(zoneId)
.toLocalDate()
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli();

// Today
List<StatusPageHistory> todayStatusPageHistoryList =
statusPageHistoryDao.findStatusPageHistoriesByComponentIdAndTimestampBetween(
component.getId(), todayStartTimestamp, nowTimestamp);

StatusPageHistory todayStatus =
combineOneDayStatusPageHistory(todayStatusPageHistoryList, component, nowTimestamp);

histories.add(todayStatus);
// query 30d component status history
LocalDateTime preTime = todayStartTime.minusDays(HISTORY_SPAN_DAYS);
long preTimestamp = preTime.toInstant(zoneOffset).toEpochMilli();
List<StatusPageHistory> history = statusPageHistoryDao
.findStatusPageHistoriesByComponentIdAndTimestampBetween(component.getId(), preTimestamp, todayStartTimestamp);

// Previous 29 days
long preTimestamp = now
.atZone(zoneId)
.toLocalDate()
.minusDays(HISTORY_SPAN_DAYS)
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli();


List<StatusPageHistory> history =
statusPageHistoryDao.findStatusPageHistoriesByComponentIdAndTimestampBetween(
component.getId(), preTimestamp, todayStartTimestamp);

LinkedList<StatusPageHistory> historyList = new LinkedList<>(history);
historyList.sort((o1, o2) -> (int) (o1.getTimestamp() - o2.getTimestamp()));
LocalDateTime endTime = todayStartTime.minusSeconds(1);
LocalDateTime startTime = endTime.withHour(0).withMinute(0).withSecond(0).withNano(0);
for (int index = 0; index < HISTORY_SPAN_DAYS; index++) {
long startTimestamp = startTime.toInstant(zoneOffset).toEpochMilli();
long endTimestamp = endTime.toInstant(zoneOffset).toEpochMilli();
List<StatusPageHistory> thisDayHistory = historyList.stream().filter(item ->
item.getTimestamp() >= startTimestamp && item.getTimestamp() <= endTimestamp)
.collect(Collectors.toList());
historyList.sort((o1, o2) -> Long.compare(o1.getTimestamp(), o2.getTimestamp()));

ZonedDateTime end = Instant.ofEpochMilli(todayStartTimestamp)
.atZone(zoneId)
.minusSeconds(1); // yesterday 23:59:59 local time

for (int i = 0; i < HISTORY_SPAN_DAYS; i++) {
long endTimestamp = end.toInstant().toEpochMilli();

long startTimestamp = end.toLocalDate()
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli();

List<StatusPageHistory> thisDayHistory = historyList.stream()
.filter(h -> h.getTimestamp() >= startTimestamp && h.getTimestamp() <= endTimestamp)
.collect(Collectors.toList());

if (thisDayHistory.isEmpty()) {
StatusPageHistory statusPageHistory = StatusPageHistory.builder().timestamp(endTimestamp)
.componentId(component.getId()).state(CommonConstants.STATUS_PAGE_COMPONENT_STATE_UNKNOWN).build();
histories.add(statusPageHistory);
histories.add(StatusPageHistory.builder()
.timestamp(endTimestamp)
.componentId(component.getId())
.state(CommonConstants.STATUS_PAGE_COMPONENT_STATE_UNKNOWN)
.build());
} else if (thisDayHistory.size() == 1) {
histories.add(thisDayHistory.get(0));
} else {
StatusPageHistory statusPageHistory = combineOneDayStatusPageHistory(thisDayHistory, component, endTimestamp);
histories.add(statusPageHistory);
StatusPageHistory merged =
combineOneDayStatusPageHistory(thisDayHistory, component, endTimestamp);
histories.add(merged);
statusPageHistoryDao.deleteAll(thisDayHistory);
statusPageHistoryDao.save(statusPageHistory);
statusPageHistoryDao.save(merged);
}
startTime = startTime.minusDays(1);
endTime = endTime.minusDays(1);

end = end.minusDays(1);
}

Copilot AI Jan 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The date calculation logic in queryComponentStatus has been significantly refactored to fix midnight race conditions and DST issues, but there are no unit tests covering this critical logic. Consider adding tests that verify the date calculations work correctly, especially for edge cases like midnight boundaries, DST transitions, and the 29-day history window calculation.

Copilot uses AI. Check for mistakes.
tomsun28 and others added 4 commits January 15, 2026 23:48
…ervice/impl/StatusPageServiceImpl.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
…ervice/impl/StatusPageServiceImpl.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
…ervice/impl/StatusPageServiceImpl.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
…ervice/impl/StatusPageServiceImpl.java

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
@tomsun28

Copy link
Copy Markdown
Member

hi thanks! Can you help to add more unit test for these?

@BhanuNidumolu

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I will add unit tests to cover:

  • Midnight boundary cases
  • DST transitions (23h / 25h days)
  • 29-day history window calculation

I’ll push the tests shortly.

@BhanuNidumolu

Copy link
Copy Markdown
Contributor Author

Added unit tests covering:

  • Midnight boundary
  • DST (23h / 25h days)
  • 29-day history window

All tests pass locally. Thanks for the review!

@tomsun28

Copy link
Copy Markdown
Member

hi LGTM! thanks.
Please add the License Header to fix license check.

@BhanuNidumolu

Copy link
Copy Markdown
Contributor Author

License header added as requested. All checks should now pass.
Thanks for the review!

@tomsun28 tomsun28 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@tomsun28 tomsun28 merged commit 8962253 into apache:master Jan 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants