diff --git a/CONTEXT.md b/CONTEXT.md index 7b59f161..67c68bac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,16 +16,34 @@ _Avoid_: Device login, token slot The single **User Session** currently allowed to access protected OnTime APIs. _Avoid_: Current token, latest device +**Schedule**: +A planned appointment or event that a **User** wants OnTime to help them prepare for. +_Avoid_: Event, calendar item + +**Preparation**: +A step a **User** needs to complete before a **Schedule**. +_Avoid_: Task, checklist item + +**Alarm Window**: +A bounded time range where OnTime identifies **Schedules** that need alarm coverage. +_Avoid_: Notification window, schedule window + ## Relationships - A **User** has at most one **Active Session**. - A **User Session** belongs to exactly one **User**. +- A **User** owns zero or more **Schedules**. +- A **Schedule** uses one or more **Preparations** to determine when the **User** should start getting ready. +- An **Alarm Window** contains zero or more **Schedules** for a single **User**. ## Example Dialogue > **Dev:** "If a **User** signs in on a second phone, do both phones keep an **Active Session**?" > **Domain expert:** "No. The second sign-in becomes the **Active Session**, and the previous **User Session** is no longer allowed to use protected APIs." +> **Dev:** "When the app asks for an **Alarm Window**, should it include the **Preparations** for each **Schedule**?" +> **Domain expert:** "Yes. The app needs those **Preparations** to calculate when the **User** should start getting ready." + ## Flagged Ambiguities - "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. diff --git a/docs/performance/alarm-window-query-count.md b/docs/performance/alarm-window-query-count.md new file mode 100644 index 00000000..2cb60760 --- /dev/null +++ b/docs/performance/alarm-window-query-count.md @@ -0,0 +1,34 @@ +# Alarm Window Query Count and Load Test + +`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. + +## Scenario + +- Dataset: 1 user, 25 `NOT_ENDED` schedules in `DEFAULT` preparation mode, 3 default preparation steps. +- Endpoint: `/schedules/alarm-window?startDate=2026-07-01T00:00:00&endDate=2026-07-15T00:00:00` +- HTTP load: k6, 10 virtual users, 30 seconds. +- Runtime: local Spring Boot server backed by H2 in-memory database. + +## Results + +| Metric | Before | After | Change | +| --- | ---: | ---: | ---: | +| DB prepared statements/request | 52 | 4 | -92.3% | +| HTTP avg latency | 5.86 ms | 2.83 ms | -51.6% | +| HTTP p50 latency | 3.77 ms | 1.99 ms | -47.3% | +| HTTP p95 latency | 14.38 ms | 7.08 ms | -50.7% | +| Throughput | 1,559 req/s | 2,965 req/s | +90.2% | +| HTTP failure rate | 0.00% | 0.00% | no change | + +## Commands + +```bash +node scripts/benchmarks/alarm-window/generate-data.mjs build/benchmarks/alarm-window +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 +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 +./gradlew test --tests devkor.ontime_back.service.ScheduleAlarmWindowQueryCountTest +``` + +## Notes + +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. diff --git a/ontime-back/scripts/benchmarks/alarm-window/generate-data.mjs b/ontime-back/scripts/benchmarks/alarm-window/generate-data.mjs new file mode 100644 index 00000000..ba17a7cc --- /dev/null +++ b/ontime-back/scripts/benchmarks/alarm-window/generate-data.mjs @@ -0,0 +1,87 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const outDir = process.argv[2] ?? "build/benchmarks/alarm-window"; +const scheduleCount = Number(process.env.SCHEDULE_COUNT ?? 25); +const secret = process.env.JWT_SECRET_KEY ?? "test_secret_key_for_ontime_back_application_tests_1234567890"; +const userId = 1; +const email = "alarm-window-load@example.com"; + +fs.mkdirSync(outDir, { recursive: true }); + +function base64url(input) { + return Buffer.from(input) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function signJwt(payload) { + const header = { alg: "HS512", typ: "JWT" }; + const encodedHeader = base64url(JSON.stringify(header)); + const encodedPayload = base64url(JSON.stringify(payload)); + const signature = crypto + .createHmac("sha512", secret) + .update(`${encodedHeader}.${encodedPayload}`) + .digest("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return `${encodedHeader}.${encodedPayload}.${signature}`; +} + +function sqlString(value) { + return String(value).replaceAll("'", "''"); +} + +function uuid(index) { + return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; +} + +const nowSeconds = Math.floor(Date.now() / 1000); +const accessToken = signJwt({ + sub: "AccessToken", + exp: nowSeconds + 60 * 60, + jti: crypto.randomUUID(), + email, + userId, +}); + +const statements = []; +statements.push( + `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);` +); +statements.push( + "INSERT INTO place (place_id, place_name) VALUES ('00000000-0000-4000-8000-000000000001', 'Load Test Place');" +); +statements.push( + "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);" +); +statements.push( + "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');" +); +statements.push( + "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');" +); +statements.push( + "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');" +); + +for (let i = 0; i < scheduleCount; i += 1) { + const day = String(1 + Math.floor(i / 8)).padStart(2, "0"); + const hour = String(8 + (i % 8)).padStart(2, "0"); + statements.push( + `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);` + ); +} + +const dataSql = `${statements.join("\n")}\n`; +const tokenJson = JSON.stringify({ accessToken, userId, scheduleCount }, null, 2); + +fs.writeFileSync(path.join(outDir, "data.sql"), dataSql); +fs.writeFileSync(path.join(outDir, "token.json"), `${tokenJson}\n`); + +console.log(`Wrote ${path.join(outDir, "data.sql")}`); +console.log(`Wrote ${path.join(outDir, "token.json")}`); diff --git a/ontime-back/scripts/benchmarks/alarm-window/k6.js b/ontime-back/scripts/benchmarks/alarm-window/k6.js new file mode 100644 index 00000000..60a805d9 --- /dev/null +++ b/ontime-back/scripts/benchmarks/alarm-window/k6.js @@ -0,0 +1,35 @@ +import http from "k6/http"; +import { check } from "k6"; + +export const options = { + scenarios: { + alarm_window: { + executor: "constant-vus", + vus: Number(__ENV.VUS ?? 10), + duration: __ENV.DURATION ?? "30s", + }, + }, + thresholds: { + http_req_failed: ["rate<0.01"], + }, +}; + +const baseUrl = __ENV.BASE_URL ?? "http://localhost:8080"; +const token = __ENV.ACCESS_TOKEN; + +export default function () { + const url = `${baseUrl}/schedules/alarm-window?startDate=2026-07-01T00:00:00&endDate=2026-07-15T00:00:00`; + const response = http.get(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + check(response, { + "status is 200": (res) => res.status === 200, + "returns schedules": (res) => { + const body = res.json(); + return Array.isArray(body.data) && body.data.length > 0; + }, + }); +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java index e47f6253..312bc406 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java @@ -20,6 +20,13 @@ public interface PreparationScheduleRepository extends JpaRepository findByScheduleWithNextPreparation(@Param("schedule") Schedule schedule); + @Query("SELECT ps FROM PreparationSchedule ps " + + "JOIN FETCH ps.schedule s " + + "LEFT JOIN FETCH ps.nextPreparation " + + "WHERE ps.schedule IN :schedules " + + "ORDER BY s.scheduleId ASC, ps.orderIndex ASC, ps.preparationScheduleId ASC") + List findBySchedulesWithNextPreparation(@Param("schedules") List schedules); + void deleteBySchedule(Schedule schedule); boolean existsBySchedule(Schedule schedule); diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateStepRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateStepRepository.java index 0ea126d7..4aa2a499 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateStepRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateStepRepository.java @@ -17,6 +17,12 @@ public interface PreparationTemplateStepRepository extends JpaRepository findByPreparationTemplateOrdered(@Param("template") PreparationTemplate template); + @Query("SELECT pts FROM PreparationTemplateStep pts " + + "JOIN FETCH pts.preparationTemplate pt " + + "WHERE pts.preparationTemplate IN :templates " + + "ORDER BY pt.preparationTemplateId ASC, pts.orderIndex ASC, pts.preparationTemplateStepId ASC") + List findByPreparationTemplatesOrdered(@Param("templates") List templates); + void deleteByPreparationTemplate(PreparationTemplate preparationTemplate); boolean existsByPreparationTemplateStepIdAndPreparationTemplate(UUID preparationTemplateStepId, PreparationTemplate preparationTemplate); diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java index 1f97ae6c..f71f6c8b 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java @@ -59,8 +59,8 @@ public interface ScheduleRepository extends JpaRepository { List findAllByUserId(@Param("userId") Long userId); @Query("SELECT s FROM Schedule s " + - "JOIN FETCH s.user " + "LEFT JOIN FETCH s.place " + + "LEFT JOIN FETCH s.preparationTemplate " + "WHERE s.user.id = :userId " + "AND s.doneStatus = :doneStatus " + "AND s.scheduleTime >= :startDate " + diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java index 4c71f8f6..db1dcadf 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java @@ -394,9 +394,11 @@ public List getAlarmWindowSchedules(Long userId, LocalDa List schedules = scheduleRepository.findAlarmWindowSchedules(userId, startDate, endDate, DoneStatus.NOT_ENDED); Integer defaultAlarmOffsetMinutes = alarmService.getDefaultAlarmOffsetMinutes(userId); + Integer userSpareTime = userRepository.findSpareTimeById(userId); + AlarmWindowPreparationLookup preparationLookup = preloadAlarmWindowPreparations(userId, schedules); return schedules.stream() - .map(schedule -> mapToAlarmWindowDto(schedule, defaultAlarmOffsetMinutes)) + .map(schedule -> mapToAlarmWindowDto(schedule, defaultAlarmOffsetMinutes, userSpareTime, preparationLookup)) .collect(Collectors.toList()); } @@ -421,15 +423,24 @@ private ScheduleDto mapToDto(Schedule schedule) { ); } - private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer defaultAlarmOffsetMinutes) { - List preparations = resolvePreparationDtos(schedule); + private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, + Integer defaultAlarmOffsetMinutes, + Integer userSpareTime, + AlarmWindowPreparationLookup preparationLookup) { + List preparations = preparationLookup != null + ? preparationLookup.resolve(schedule) + : resolvePreparationDtos(schedule); int totalPreparationTime = preparations.stream() .map(PreparationDto::getPreparationTime) .map(this::defaultNonNegative) .reduce(0, Integer::sum); int moveTime = defaultNonNegative(schedule.getMoveTime()); - int scheduleSpareTime = getEffectiveSpareTime(schedule); + int scheduleSpareTime = getEffectiveSpareTime(schedule, userSpareTime); + PreparationMode preparationMode = schedule.effectivePreparationMode(); + PreparationTemplate preparationTemplate = preparationMode == PreparationMode.TEMPLATE + ? schedule.getPreparationTemplate() + : null; LocalDateTime preparationStartTime = schedule.getScheduleTime() .minusMinutes((long) totalPreparationTime + moveTime + scheduleSpareTime); @@ -445,10 +456,10 @@ private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer de .doneStatus(schedule.getDoneStatus()) .startedAt(schedule.getStartedAt()) .finishedAt(schedule.getFinishedAt()) - .preparationMode(schedule.effectivePreparationMode()) - .preparationTemplateId(schedule.getPreparationTemplate() != null ? schedule.getPreparationTemplate().getPreparationTemplateId() : null) - .preparationTemplateName(schedule.getPreparationTemplate() != null ? schedule.getPreparationTemplate().getTemplateName() : null) - .preparationTemplateDeleted(schedule.getPreparationTemplate() != null && schedule.getPreparationTemplate().isDeleted()) + .preparationMode(preparationMode) + .preparationTemplateId(preparationTemplate != null ? preparationTemplate.getPreparationTemplateId() : null) + .preparationTemplateName(preparationTemplate != null ? preparationTemplate.getTemplateName() : null) + .preparationTemplateDeleted(preparationTemplate != null && preparationTemplate.isDeleted()) .preparationFrozen(schedule.getStartedAt() != null) .preparationStartTime(preparationStartTime) .defaultAlarmTime(defaultAlarmTime) @@ -457,6 +468,57 @@ private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer de .build(); } + private AlarmWindowPreparationLookup preloadAlarmWindowPreparations(Long userId, List schedules) { + if (schedules.isEmpty()) { + return new AlarmWindowPreparationLookup(List.of(), Map.of(), Map.of()); + } + + boolean hasDefaultPreparationSchedule = schedules.stream() + .anyMatch(schedule -> schedule.getStartedAt() == null + && schedule.effectivePreparationMode() == PreparationMode.DEFAULT); + List defaultPreparations = List.of(); + if (hasDefaultPreparationSchedule) { + defaultPreparations = preparationStepService.toLinkedDtoFromUser( + preparationUserRepository.findByUserIdWithNextPreparation(userId) + ); + } + + List scheduleSpecificPreparationSchedules = schedules.stream() + .filter(schedule -> schedule.getStartedAt() != null || schedule.effectivePreparationMode() == PreparationMode.CUSTOM) + .toList(); + Map> preparationsByScheduleId = scheduleSpecificPreparationSchedules.isEmpty() + ? Map.of() + : preparationScheduleRepository.findBySchedulesWithNextPreparation(scheduleSpecificPreparationSchedules) + .stream() + .collect(Collectors.groupingBy( + preparationSchedule -> preparationSchedule.getSchedule().getScheduleId(), + Collectors.collectingAndThen(Collectors.toList(), preparationStepService::toLinkedDtoFromSchedule) + )); + + List templates = schedules.stream() + .filter(schedule -> schedule.effectivePreparationMode() == PreparationMode.TEMPLATE) + .map(Schedule::getPreparationTemplate) + .filter(template -> template != null) + .collect(Collectors.toMap( + PreparationTemplate::getPreparationTemplateId, + template -> template, + (first, ignored) -> first + )) + .values() + .stream() + .toList(); + Map> preparationsByTemplateId = templates.isEmpty() + ? Map.of() + : preparationTemplateStepRepository.findByPreparationTemplatesOrdered(templates) + .stream() + .collect(Collectors.groupingBy( + templateStep -> templateStep.getPreparationTemplate().getPreparationTemplateId(), + Collectors.collectingAndThen(Collectors.toList(), preparationStepService::toLinkedDtoFromTemplate) + )); + + return new AlarmWindowPreparationLookup(defaultPreparations, preparationsByScheduleId, preparationsByTemplateId); + } + private PreparationDto mapPreparationScheduleToDto(PreparationSchedule preparationSchedule) { return new PreparationDto( preparationSchedule.getPreparationScheduleId(), @@ -610,10 +672,14 @@ private PreparationDto mapPreparationUserToDto(PreparationUser preparationUser) } private Integer getEffectiveSpareTime(Schedule schedule) { + return getEffectiveSpareTime(schedule, null); + } + + private Integer getEffectiveSpareTime(Schedule schedule, Integer userSpareTime) { if (schedule.getScheduleSpareTime() != null) { return defaultNonNegative(schedule.getScheduleSpareTime()); } - return defaultNonNegative(schedule.getUser().getSpareTime()); + return defaultNonNegative(userSpareTime != null ? userSpareTime : schedule.getUser().getSpareTime()); } private Integer defaultNonNegative(Integer value) { @@ -624,4 +690,24 @@ private Instant nowForPersistence() { return Instant.now().truncatedTo(ChronoUnit.SECONDS); } + private record AlarmWindowPreparationLookup( + List defaultPreparations, + Map> preparationsByScheduleId, + Map> preparationsByTemplateId + ) { + private List resolve(Schedule schedule) { + if (schedule.getStartedAt() != null || schedule.effectivePreparationMode() == PreparationMode.CUSTOM) { + return preparationsByScheduleId.getOrDefault(schedule.getScheduleId(), List.of()); + } + if (schedule.effectivePreparationMode() == PreparationMode.TEMPLATE) { + PreparationTemplate template = schedule.getPreparationTemplate(); + if (template == null) { + throw new GeneralException(PREPARATION_TEMPLATE_NOT_FOUND); + } + return preparationsByTemplateId.getOrDefault(template.getPreparationTemplateId(), List.of()); + } + return defaultPreparations; + } + } + } diff --git a/ontime-back/src/test/java/devkor/ontime_back/SqlStatementCollector.java b/ontime-back/src/test/java/devkor/ontime_back/SqlStatementCollector.java new file mode 100644 index 00000000..425688be --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/SqlStatementCollector.java @@ -0,0 +1,24 @@ +package devkor.ontime_back; + +import org.hibernate.resource.jdbc.spi.StatementInspector; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public class SqlStatementCollector implements StatementInspector { + private static final List STATEMENTS = new CopyOnWriteArrayList<>(); + + @Override + public String inspect(String sql) { + STATEMENTS.add(sql); + return sql; + } + + public static void clear() { + STATEMENTS.clear(); + } + + public static List statements() { + return List.copyOf(STATEMENTS); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleAlarmWindowQueryCountTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleAlarmWindowQueryCountTest.java new file mode 100644 index 00000000..59e32add --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleAlarmWindowQueryCountTest.java @@ -0,0 +1,175 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.SqlStatementCollector; +import devkor.ontime_back.dto.AlarmWindowScheduleDto; +import devkor.ontime_back.entity.DoneStatus; +import devkor.ontime_back.entity.Place; +import devkor.ontime_back.entity.PreparationMode; +import devkor.ontime_back.entity.PreparationUser; +import devkor.ontime_back.entity.Schedule; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.repository.PlaceRepository; +import devkor.ontime_back.repository.PreparationUserRepository; +import devkor.ontime_back.repository.ScheduleRepository; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = { + "spring.jpa.properties.hibernate.generate_statistics=true", + "spring.jpa.properties.hibernate.session_factory.statement_inspector=devkor.ontime_back.SqlStatementCollector" +}) +@Transactional +class ScheduleAlarmWindowQueryCountTest { + + @Autowired + private ScheduleService scheduleService; + + @Autowired + private ScheduleRepository scheduleRepository; + + @Autowired + private PreparationUserRepository preparationUserRepository; + + @Autowired + private UserAlarmSettingRepository userAlarmSettingRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PlaceRepository placeRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @AfterEach + void tearDown() { + userAlarmSettingRepository.deleteAllInBatch(); + preparationUserRepository.deleteAllInBatch(); + scheduleRepository.deleteAllInBatch(); + placeRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("알람 윈도우 DEFAULT 준비과정 조회는 스케줄 수에 비례해 SQL이 증가하지 않는다") + void alarmWindowDefaultPreparationQueryCountDoesNotScaleWithScheduleCount() { + int scheduleCount = 25; + User user = createUser(); + Place place = placeRepository.save(new Place(UUID.randomUUID(), "연구실")); + createDefaultPreparations(user); + createDefaultModeSchedules(user, place, scheduleCount); + + entityManager.flush(); + entityManager.clear(); + + Statistics statistics = statistics(); + statistics.clear(); + SqlStatementCollector.clear(); + + List result = scheduleService.getAlarmWindowSchedules( + user.getId(), + LocalDateTime.of(2026, 7, 1, 0, 0), + LocalDateTime.of(2026, 7, 15, 0, 0) + ); + + long statementCount = statistics.getPrepareStatementCount(); + System.out.printf("alarm-window DEFAULT schedules=%d preparedStatements=%d%n", scheduleCount, statementCount); + SqlStatementCollector.statements().stream() + .collect(Collectors.groupingBy(sql -> sql, Collectors.counting())) + .entrySet() + .stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(5) + .forEach(entry -> System.out.printf("sqlCount=%d sql=%s%n", entry.getValue(), entry.getKey())); + + assertThat(result).hasSize(scheduleCount); + assertThat(result) + .allSatisfy(schedule -> assertThat(schedule.getPreparations()).hasSize(3)); + assertThat(statementCount) + .as("1 schedule query + 1 alarm setting query + 1 user spare-time query + 1 default preparation query") + .isLessThanOrEqualTo(4L); + } + + private User createUser() { + return userRepository.save(User.builder() + .email("alarm-window@example.com") + .password("password") + .name("alarm-user") + .spareTime(10) + .punctualityScore(-1f) + .scheduleCountAfterReset(0) + .latenessCountAfterReset(0) + .build()); + } + + private void createDefaultPreparations(User user) { + PreparationUser third = preparationUserRepository.save(PreparationUser.builder() + .preparationUserId(UUID.randomUUID()) + .user(user) + .preparationName("가방 챙기기") + .preparationTime(5) + .orderIndex(2) + .build()); + PreparationUser second = preparationUserRepository.save(PreparationUser.builder() + .preparationUserId(UUID.randomUUID()) + .user(user) + .preparationName("옷 입기") + .preparationTime(10) + .orderIndex(1) + .nextPreparation(third) + .build()); + preparationUserRepository.save(PreparationUser.builder() + .preparationUserId(UUID.randomUUID()) + .user(user) + .preparationName("세수하기") + .preparationTime(10) + .orderIndex(0) + .nextPreparation(second) + .build()); + } + + private void createDefaultModeSchedules(User user, Place place, int scheduleCount) { + LocalDateTime baseTime = LocalDateTime.of(2026, 7, 1, 9, 0); + for (int i = 0; i < scheduleCount; i++) { + scheduleRepository.save(Schedule.builder() + .scheduleId(UUID.randomUUID()) + .user(user) + .place(place) + .scheduleName("회의 " + i) + .moveTime(20) + .scheduleTime(baseTime.plusHours(i)) + .isChange(false) + .preparationMode(PreparationMode.DEFAULT) + .scheduleSpareTime(null) + .latenessTime(-1) + .doneStatus(DoneStatus.NOT_ENDED) + .build()); + } + } + + private Statistics statistics() { + return entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + } +}