Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions docs/performance/alarm-window-query-count.md
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 87 additions & 0 deletions ontime-back/scripts/benchmarks/alarm-window/generate-data.mjs
Original file line number Diff line number Diff line change
@@ -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")}`);
35 changes: 35 additions & 0 deletions ontime-back/scripts/benchmarks/alarm-window/k6.js
Original file line number Diff line number Diff line change
@@ -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;
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ public interface PreparationScheduleRepository extends JpaRepository<Preparation
"ORDER BY ps.orderIndex ASC, ps.preparationScheduleId ASC")
List<PreparationSchedule> 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<PreparationSchedule> findBySchedulesWithNextPreparation(@Param("schedules") List<Schedule> schedules);

void deleteBySchedule(Schedule schedule);

boolean existsBySchedule(Schedule schedule);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ public interface PreparationTemplateStepRepository extends JpaRepository<Prepara
"ORDER BY pts.orderIndex ASC, pts.preparationTemplateStepId ASC")
List<PreparationTemplateStep> 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<PreparationTemplateStep> findByPreparationTemplatesOrdered(@Param("templates") List<PreparationTemplate> templates);

void deleteByPreparationTemplate(PreparationTemplate preparationTemplate);

boolean existsByPreparationTemplateStepIdAndPreparationTemplate(UUID preparationTemplateStepId, PreparationTemplate preparationTemplate);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ public interface ScheduleRepository extends JpaRepository<Schedule, UUID> {
List<Schedule> 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 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,11 @@ public List<AlarmWindowScheduleDto> getAlarmWindowSchedules(Long userId, LocalDa

List<Schedule> 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());
}

Expand All @@ -421,15 +423,24 @@ private ScheduleDto mapToDto(Schedule schedule) {
);
}

private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer defaultAlarmOffsetMinutes) {
List<PreparationDto> preparations = resolvePreparationDtos(schedule);
private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule,
Integer defaultAlarmOffsetMinutes,
Integer userSpareTime,
AlarmWindowPreparationLookup preparationLookup) {
List<PreparationDto> 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);
Expand All @@ -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)
Expand All @@ -457,6 +468,57 @@ private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer de
.build();
}

private AlarmWindowPreparationLookup preloadAlarmWindowPreparations(Long userId, List<Schedule> 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<PreparationDto> defaultPreparations = List.of();
if (hasDefaultPreparationSchedule) {
defaultPreparations = preparationStepService.toLinkedDtoFromUser(
preparationUserRepository.findByUserIdWithNextPreparation(userId)
);
}

List<Schedule> scheduleSpecificPreparationSchedules = schedules.stream()
.filter(schedule -> schedule.getStartedAt() != null || schedule.effectivePreparationMode() == PreparationMode.CUSTOM)
.toList();
Map<UUID, List<PreparationDto>> preparationsByScheduleId = scheduleSpecificPreparationSchedules.isEmpty()
? Map.of()
: preparationScheduleRepository.findBySchedulesWithNextPreparation(scheduleSpecificPreparationSchedules)
.stream()
.collect(Collectors.groupingBy(
preparationSchedule -> preparationSchedule.getSchedule().getScheduleId(),
Collectors.collectingAndThen(Collectors.toList(), preparationStepService::toLinkedDtoFromSchedule)
));

List<PreparationTemplate> 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<UUID, List<PreparationDto>> 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(),
Expand Down Expand Up @@ -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) {
Expand All @@ -624,4 +690,24 @@ private Instant nowForPersistence() {
return Instant.now().truncatedTo(ChronoUnit.SECONDS);
}

private record AlarmWindowPreparationLookup(
List<PreparationDto> defaultPreparations,
Map<UUID, List<PreparationDto>> preparationsByScheduleId,
Map<UUID, List<PreparationDto>> preparationsByTemplateId
) {
private List<PreparationDto> 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;
}
}

}
Loading
Loading