Skip to content

Commit bab08a1

Browse files
committed
Optimize alarm window preparation loading
1 parent de4cf10 commit bab08a1

10 files changed

Lines changed: 482 additions & 10 deletions

File tree

CONTEXT.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,34 @@ _Avoid_: Device login, token slot
1616
The single **User Session** currently allowed to access protected OnTime APIs.
1717
_Avoid_: Current token, latest device
1818

19+
**Schedule**:
20+
A planned appointment or event that a **User** wants OnTime to help them prepare for.
21+
_Avoid_: Event, calendar item
22+
23+
**Preparation**:
24+
A step a **User** needs to complete before a **Schedule**.
25+
_Avoid_: Task, checklist item
26+
27+
**Alarm Window**:
28+
A bounded time range where OnTime identifies **Schedules** that need alarm coverage.
29+
_Avoid_: Notification window, schedule window
30+
1931
## Relationships
2032

2133
- A **User** has at most one **Active Session**.
2234
- A **User Session** belongs to exactly one **User**.
35+
- A **User** owns zero or more **Schedules**.
36+
- A **Schedule** uses one or more **Preparations** to determine when the **User** should start getting ready.
37+
- An **Alarm Window** contains zero or more **Schedules** for a single **User**.
2338

2439
## Example Dialogue
2540

2641
> **Dev:** "If a **User** signs in on a second phone, do both phones keep an **Active Session**?"
2742
> **Domain expert:** "No. The second sign-in becomes the **Active Session**, and the previous **User Session** is no longer allowed to use protected APIs."
2843
44+
> **Dev:** "When the app asks for an **Alarm Window**, should it include the **Preparations** for each **Schedule**?"
45+
> **Domain expert:** "Yes. The app needs those **Preparations** to calculate when the **User** should start getting ready."
46+
2947
## Flagged Ambiguities
3048

3149
- "device login" was used to mean both a physical device and an authenticated **User Session**. Resolved: the login limit applies to **User Sessions**, not to registered alarm devices.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Alarm Window Query Count and Load Test
2+
3+
`GET /schedules/alarm-window` previously repeated preparation and user-setting reads while mapping each schedule in the response. The optimized path preloads preparation data for the alarm window and avoids fetching the full user graph for each schedule.
4+
5+
## Scenario
6+
7+
- Dataset: 1 user, 25 `NOT_ENDED` schedules in `DEFAULT` preparation mode, 3 default preparation steps.
8+
- Endpoint: `/schedules/alarm-window?startDate=2026-07-01T00:00:00&endDate=2026-07-15T00:00:00`
9+
- HTTP load: k6, 10 virtual users, 30 seconds.
10+
- Runtime: local Spring Boot server backed by H2 in-memory database.
11+
12+
## Results
13+
14+
| Metric | Before | After | Change |
15+
| --- | ---: | ---: | ---: |
16+
| DB prepared statements/request | 52 | 4 | -92.3% |
17+
| HTTP avg latency | 5.86 ms | 2.83 ms | -51.6% |
18+
| HTTP p50 latency | 3.77 ms | 1.99 ms | -47.3% |
19+
| HTTP p95 latency | 14.38 ms | 7.08 ms | -50.7% |
20+
| Throughput | 1,559 req/s | 2,965 req/s | +90.2% |
21+
| HTTP failure rate | 0.00% | 0.00% | no change |
22+
23+
## Commands
24+
25+
```bash
26+
node scripts/benchmarks/alarm-window/generate-data.mjs build/benchmarks/alarm-window
27+
BASE_URL=http://localhost:18080 ACCESS_TOKEN="$(jq -r .accessToken build/benchmarks/alarm-window/token.json)" VUS=10 DURATION=30s k6 run --summary-export build/benchmarks/alarm-window/before-quiet-summary.json scripts/benchmarks/alarm-window/k6.js
28+
BASE_URL=http://localhost:18081 ACCESS_TOKEN="$(jq -r .accessToken build/benchmarks/alarm-window/token.json)" VUS=10 DURATION=30s k6 run --summary-export build/benchmarks/alarm-window/after-summary.json scripts/benchmarks/alarm-window/k6.js
29+
./gradlew test --tests devkor.ontime_back.service.ScheduleAlarmWindowQueryCountTest
30+
```
31+
32+
## Notes
33+
34+
The HTTP benchmark is intended as local comparative evidence, not a production capacity number. During the H2 load run, asynchronous API log inserts can report H2 identity primary-key collisions after responses have already succeeded; k6 still recorded 0 HTTP failures in both before and after runs.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import crypto from "node:crypto";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
5+
const outDir = process.argv[2] ?? "build/benchmarks/alarm-window";
6+
const scheduleCount = Number(process.env.SCHEDULE_COUNT ?? 25);
7+
const secret = process.env.JWT_SECRET_KEY ?? "test_secret_key_for_ontime_back_application_tests_1234567890";
8+
const userId = 1;
9+
const email = "alarm-window-load@example.com";
10+
11+
fs.mkdirSync(outDir, { recursive: true });
12+
13+
function base64url(input) {
14+
return Buffer.from(input)
15+
.toString("base64")
16+
.replace(/=/g, "")
17+
.replace(/\+/g, "-")
18+
.replace(/\//g, "_");
19+
}
20+
21+
function signJwt(payload) {
22+
const header = { alg: "HS512", typ: "JWT" };
23+
const encodedHeader = base64url(JSON.stringify(header));
24+
const encodedPayload = base64url(JSON.stringify(payload));
25+
const signature = crypto
26+
.createHmac("sha512", secret)
27+
.update(`${encodedHeader}.${encodedPayload}`)
28+
.digest("base64")
29+
.replace(/=/g, "")
30+
.replace(/\+/g, "-")
31+
.replace(/\//g, "_");
32+
return `${encodedHeader}.${encodedPayload}.${signature}`;
33+
}
34+
35+
function sqlString(value) {
36+
return String(value).replaceAll("'", "''");
37+
}
38+
39+
function uuid(index) {
40+
return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
41+
}
42+
43+
const nowSeconds = Math.floor(Date.now() / 1000);
44+
const accessToken = signJwt({
45+
sub: "AccessToken",
46+
exp: nowSeconds + 60 * 60,
47+
jti: crypto.randomUUID(),
48+
email,
49+
userId,
50+
});
51+
52+
const statements = [];
53+
statements.push(
54+
`INSERT INTO user (user_id, email, password, name, spare_time, note, punctuality_score, schedule_count_after_reset, lateness_count_after_reset, role, social_type, social_id, access_token, refresh_token, firebase_token, social_login_token, image_url) VALUES (${userId}, '${email}', 'password', 'loaduser', 10, NULL, -1, 0, 0, 'USER', NULL, NULL, '${sqlString(accessToken)}', NULL, NULL, NULL, NULL);`
55+
);
56+
statements.push(
57+
"INSERT INTO place (place_id, place_name) VALUES ('00000000-0000-4000-8000-000000000001', 'Load Test Place');"
58+
);
59+
statements.push(
60+
"INSERT INTO preparation_user (preparation_user_id, user_id, preparation_name, preparation_time, order_index, next_preparation_id) VALUES ('00000000-0000-4000-8000-100000000003', 1, 'Pack bag', 5, 2, NULL);"
61+
);
62+
statements.push(
63+
"INSERT INTO preparation_user (preparation_user_id, user_id, preparation_name, preparation_time, order_index, next_preparation_id) VALUES ('00000000-0000-4000-8000-100000000002', 1, 'Get dressed', 10, 1, '00000000-0000-4000-8000-100000000003');"
64+
);
65+
statements.push(
66+
"INSERT INTO preparation_user (preparation_user_id, user_id, preparation_name, preparation_time, order_index, next_preparation_id) VALUES ('00000000-0000-4000-8000-100000000001', 1, 'Wash up', 10, 0, '00000000-0000-4000-8000-100000000002');"
67+
);
68+
statements.push(
69+
"INSERT INTO user_alarm_setting (user_alarm_setting_id, user_id, alarms_enabled, default_alarm_offset_minutes, updated_at) VALUES (1, 1, TRUE, 5, '2026-07-01T00:00:00Z');"
70+
);
71+
72+
for (let i = 0; i < scheduleCount; i += 1) {
73+
const day = String(1 + Math.floor(i / 8)).padStart(2, "0");
74+
const hour = String(8 + (i % 8)).padStart(2, "0");
75+
statements.push(
76+
`INSERT INTO schedule (schedule_id, user_id, place_id, schedule_name, move_time, schedule_time, is_change, is_started, started_at, finished_at, done_status, preparation_mode, preparation_template_id, schedule_spare_time, lateness_time, schedule_note) VALUES ('${uuid(200000000000 + i)}', 1, '00000000-0000-4000-8000-000000000001', 'Load schedule ${i}', 20, '2026-07-${day}T${hour}:00:00', FALSE, FALSE, NULL, NULL, 'NOT_ENDED', 'DEFAULT', NULL, NULL, -1, NULL);`
77+
);
78+
}
79+
80+
const dataSql = `${statements.join("\n")}\n`;
81+
const tokenJson = JSON.stringify({ accessToken, userId, scheduleCount }, null, 2);
82+
83+
fs.writeFileSync(path.join(outDir, "data.sql"), dataSql);
84+
fs.writeFileSync(path.join(outDir, "token.json"), `${tokenJson}\n`);
85+
86+
console.log(`Wrote ${path.join(outDir, "data.sql")}`);
87+
console.log(`Wrote ${path.join(outDir, "token.json")}`);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import http from "k6/http";
2+
import { check } from "k6";
3+
4+
export const options = {
5+
scenarios: {
6+
alarm_window: {
7+
executor: "constant-vus",
8+
vus: Number(__ENV.VUS ?? 10),
9+
duration: __ENV.DURATION ?? "30s",
10+
},
11+
},
12+
thresholds: {
13+
http_req_failed: ["rate<0.01"],
14+
},
15+
};
16+
17+
const baseUrl = __ENV.BASE_URL ?? "http://localhost:8080";
18+
const token = __ENV.ACCESS_TOKEN;
19+
20+
export default function () {
21+
const url = `${baseUrl}/schedules/alarm-window?startDate=2026-07-01T00:00:00&endDate=2026-07-15T00:00:00`;
22+
const response = http.get(url, {
23+
headers: {
24+
Authorization: `Bearer ${token}`,
25+
},
26+
});
27+
28+
check(response, {
29+
"status is 200": (res) => res.status === 200,
30+
"returns schedules": (res) => {
31+
const body = res.json();
32+
return Array.isArray(body.data) && body.data.length > 0;
33+
},
34+
});
35+
}

ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ public interface PreparationScheduleRepository extends JpaRepository<Preparation
2020
"ORDER BY ps.orderIndex ASC, ps.preparationScheduleId ASC")
2121
List<PreparationSchedule> findByScheduleWithNextPreparation(@Param("schedule") Schedule schedule);
2222

23+
@Query("SELECT ps FROM PreparationSchedule ps " +
24+
"JOIN FETCH ps.schedule s " +
25+
"LEFT JOIN FETCH ps.nextPreparation " +
26+
"WHERE ps.schedule IN :schedules " +
27+
"ORDER BY s.scheduleId ASC, ps.orderIndex ASC, ps.preparationScheduleId ASC")
28+
List<PreparationSchedule> findBySchedulesWithNextPreparation(@Param("schedules") List<Schedule> schedules);
29+
2330
void deleteBySchedule(Schedule schedule);
2431

2532
boolean existsBySchedule(Schedule schedule);

ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateStepRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ public interface PreparationTemplateStepRepository extends JpaRepository<Prepara
1717
"ORDER BY pts.orderIndex ASC, pts.preparationTemplateStepId ASC")
1818
List<PreparationTemplateStep> findByPreparationTemplateOrdered(@Param("template") PreparationTemplate template);
1919

20+
@Query("SELECT pts FROM PreparationTemplateStep pts " +
21+
"JOIN FETCH pts.preparationTemplate pt " +
22+
"WHERE pts.preparationTemplate IN :templates " +
23+
"ORDER BY pt.preparationTemplateId ASC, pts.orderIndex ASC, pts.preparationTemplateStepId ASC")
24+
List<PreparationTemplateStep> findByPreparationTemplatesOrdered(@Param("templates") List<PreparationTemplate> templates);
25+
2026
void deleteByPreparationTemplate(PreparationTemplate preparationTemplate);
2127

2228
boolean existsByPreparationTemplateStepIdAndPreparationTemplate(UUID preparationTemplateStepId, PreparationTemplate preparationTemplate);

ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ public interface ScheduleRepository extends JpaRepository<Schedule, UUID> {
5959
List<Schedule> findAllByUserId(@Param("userId") Long userId);
6060

6161
@Query("SELECT s FROM Schedule s " +
62-
"JOIN FETCH s.user " +
6362
"LEFT JOIN FETCH s.place " +
63+
"LEFT JOIN FETCH s.preparationTemplate " +
6464
"WHERE s.user.id = :userId " +
6565
"AND s.doneStatus = :doneStatus " +
6666
"AND s.scheduleTime >= :startDate " +

ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,11 @@ public List<AlarmWindowScheduleDto> getAlarmWindowSchedules(Long userId, LocalDa
394394

395395
List<Schedule> schedules = scheduleRepository.findAlarmWindowSchedules(userId, startDate, endDate, DoneStatus.NOT_ENDED);
396396
Integer defaultAlarmOffsetMinutes = alarmService.getDefaultAlarmOffsetMinutes(userId);
397+
Integer userSpareTime = userRepository.findSpareTimeById(userId);
398+
AlarmWindowPreparationLookup preparationLookup = preloadAlarmWindowPreparations(userId, schedules);
397399

398400
return schedules.stream()
399-
.map(schedule -> mapToAlarmWindowDto(schedule, defaultAlarmOffsetMinutes))
401+
.map(schedule -> mapToAlarmWindowDto(schedule, defaultAlarmOffsetMinutes, userSpareTime, preparationLookup))
400402
.collect(Collectors.toList());
401403
}
402404

@@ -421,15 +423,24 @@ private ScheduleDto mapToDto(Schedule schedule) {
421423
);
422424
}
423425

424-
private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer defaultAlarmOffsetMinutes) {
425-
List<PreparationDto> preparations = resolvePreparationDtos(schedule);
426+
private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule,
427+
Integer defaultAlarmOffsetMinutes,
428+
Integer userSpareTime,
429+
AlarmWindowPreparationLookup preparationLookup) {
430+
List<PreparationDto> preparations = preparationLookup != null
431+
? preparationLookup.resolve(schedule)
432+
: resolvePreparationDtos(schedule);
426433

427434
int totalPreparationTime = preparations.stream()
428435
.map(PreparationDto::getPreparationTime)
429436
.map(this::defaultNonNegative)
430437
.reduce(0, Integer::sum);
431438
int moveTime = defaultNonNegative(schedule.getMoveTime());
432-
int scheduleSpareTime = getEffectiveSpareTime(schedule);
439+
int scheduleSpareTime = getEffectiveSpareTime(schedule, userSpareTime);
440+
PreparationMode preparationMode = schedule.effectivePreparationMode();
441+
PreparationTemplate preparationTemplate = preparationMode == PreparationMode.TEMPLATE
442+
? schedule.getPreparationTemplate()
443+
: null;
433444

434445
LocalDateTime preparationStartTime = schedule.getScheduleTime()
435446
.minusMinutes((long) totalPreparationTime + moveTime + scheduleSpareTime);
@@ -445,10 +456,10 @@ private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer de
445456
.doneStatus(schedule.getDoneStatus())
446457
.startedAt(schedule.getStartedAt())
447458
.finishedAt(schedule.getFinishedAt())
448-
.preparationMode(schedule.effectivePreparationMode())
449-
.preparationTemplateId(schedule.getPreparationTemplate() != null ? schedule.getPreparationTemplate().getPreparationTemplateId() : null)
450-
.preparationTemplateName(schedule.getPreparationTemplate() != null ? schedule.getPreparationTemplate().getTemplateName() : null)
451-
.preparationTemplateDeleted(schedule.getPreparationTemplate() != null && schedule.getPreparationTemplate().isDeleted())
459+
.preparationMode(preparationMode)
460+
.preparationTemplateId(preparationTemplate != null ? preparationTemplate.getPreparationTemplateId() : null)
461+
.preparationTemplateName(preparationTemplate != null ? preparationTemplate.getTemplateName() : null)
462+
.preparationTemplateDeleted(preparationTemplate != null && preparationTemplate.isDeleted())
452463
.preparationFrozen(schedule.getStartedAt() != null)
453464
.preparationStartTime(preparationStartTime)
454465
.defaultAlarmTime(defaultAlarmTime)
@@ -457,6 +468,57 @@ private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer de
457468
.build();
458469
}
459470

471+
private AlarmWindowPreparationLookup preloadAlarmWindowPreparations(Long userId, List<Schedule> schedules) {
472+
if (schedules.isEmpty()) {
473+
return new AlarmWindowPreparationLookup(List.of(), Map.of(), Map.of());
474+
}
475+
476+
boolean hasDefaultPreparationSchedule = schedules.stream()
477+
.anyMatch(schedule -> schedule.getStartedAt() == null
478+
&& schedule.effectivePreparationMode() == PreparationMode.DEFAULT);
479+
List<PreparationDto> defaultPreparations = List.of();
480+
if (hasDefaultPreparationSchedule) {
481+
defaultPreparations = preparationStepService.toLinkedDtoFromUser(
482+
preparationUserRepository.findByUserIdWithNextPreparation(userId)
483+
);
484+
}
485+
486+
List<Schedule> scheduleSpecificPreparationSchedules = schedules.stream()
487+
.filter(schedule -> schedule.getStartedAt() != null || schedule.effectivePreparationMode() == PreparationMode.CUSTOM)
488+
.toList();
489+
Map<UUID, List<PreparationDto>> preparationsByScheduleId = scheduleSpecificPreparationSchedules.isEmpty()
490+
? Map.of()
491+
: preparationScheduleRepository.findBySchedulesWithNextPreparation(scheduleSpecificPreparationSchedules)
492+
.stream()
493+
.collect(Collectors.groupingBy(
494+
preparationSchedule -> preparationSchedule.getSchedule().getScheduleId(),
495+
Collectors.collectingAndThen(Collectors.toList(), preparationStepService::toLinkedDtoFromSchedule)
496+
));
497+
498+
List<PreparationTemplate> templates = schedules.stream()
499+
.filter(schedule -> schedule.effectivePreparationMode() == PreparationMode.TEMPLATE)
500+
.map(Schedule::getPreparationTemplate)
501+
.filter(template -> template != null)
502+
.collect(Collectors.toMap(
503+
PreparationTemplate::getPreparationTemplateId,
504+
template -> template,
505+
(first, ignored) -> first
506+
))
507+
.values()
508+
.stream()
509+
.toList();
510+
Map<UUID, List<PreparationDto>> preparationsByTemplateId = templates.isEmpty()
511+
? Map.of()
512+
: preparationTemplateStepRepository.findByPreparationTemplatesOrdered(templates)
513+
.stream()
514+
.collect(Collectors.groupingBy(
515+
templateStep -> templateStep.getPreparationTemplate().getPreparationTemplateId(),
516+
Collectors.collectingAndThen(Collectors.toList(), preparationStepService::toLinkedDtoFromTemplate)
517+
));
518+
519+
return new AlarmWindowPreparationLookup(defaultPreparations, preparationsByScheduleId, preparationsByTemplateId);
520+
}
521+
460522
private PreparationDto mapPreparationScheduleToDto(PreparationSchedule preparationSchedule) {
461523
return new PreparationDto(
462524
preparationSchedule.getPreparationScheduleId(),
@@ -610,10 +672,14 @@ private PreparationDto mapPreparationUserToDto(PreparationUser preparationUser)
610672
}
611673

612674
private Integer getEffectiveSpareTime(Schedule schedule) {
675+
return getEffectiveSpareTime(schedule, null);
676+
}
677+
678+
private Integer getEffectiveSpareTime(Schedule schedule, Integer userSpareTime) {
613679
if (schedule.getScheduleSpareTime() != null) {
614680
return defaultNonNegative(schedule.getScheduleSpareTime());
615681
}
616-
return defaultNonNegative(schedule.getUser().getSpareTime());
682+
return defaultNonNegative(userSpareTime != null ? userSpareTime : schedule.getUser().getSpareTime());
617683
}
618684

619685
private Integer defaultNonNegative(Integer value) {
@@ -624,4 +690,24 @@ private Instant nowForPersistence() {
624690
return Instant.now().truncatedTo(ChronoUnit.SECONDS);
625691
}
626692

693+
private record AlarmWindowPreparationLookup(
694+
List<PreparationDto> defaultPreparations,
695+
Map<UUID, List<PreparationDto>> preparationsByScheduleId,
696+
Map<UUID, List<PreparationDto>> preparationsByTemplateId
697+
) {
698+
private List<PreparationDto> resolve(Schedule schedule) {
699+
if (schedule.getStartedAt() != null || schedule.effectivePreparationMode() == PreparationMode.CUSTOM) {
700+
return preparationsByScheduleId.getOrDefault(schedule.getScheduleId(), List.of());
701+
}
702+
if (schedule.effectivePreparationMode() == PreparationMode.TEMPLATE) {
703+
PreparationTemplate template = schedule.getPreparationTemplate();
704+
if (template == null) {
705+
throw new GeneralException(PREPARATION_TEMPLATE_NOT_FOUND);
706+
}
707+
return preparationsByTemplateId.getOrDefault(template.getPreparationTemplateId(), List.of());
708+
}
709+
return defaultPreparations;
710+
}
711+
}
712+
627713
}

0 commit comments

Comments
 (0)