Skip to content

perf: batch the appointment list's per-row queries and memoize the notifier - #295

Merged
luflow merged 2 commits into
mainfrom
claude/slow-save-appointments-10cef1
Oct 7, 2026
Merged

luflow merged 2 commits into
mainfrom
claude/slow-save-appointments-10cef1

Conversation

@luflow

@luflow luflow commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Two performance fixes, found by profiling the dev instance with the profiler app. No API change: payloads are byte-identical, so web and Flutter clients are unaffected.

Appointment list: one query per page instead of ten per row

GET /api/appointments issued ten-plus queries per appointment: the response summary re-read the appointment and its responses, resolved the section groups' members anew for every row (the group manager drops a group's cached members whenever getUserGroups() hands out a fresh group object, which it does once per responder), attachments and series sizes were read per row, the check-in counts re-read appointment and responses once more from the controller, and the scheduling filters read every row's responses before paging.

Now the page reads everybody's responses, attachments, series sizes and bookings once each, hands the entity and its responses to the summary and check-in tallies, and VisibilityService keeps group members, the group list and "everyone" for the request. The single-appointment endpoint takes the same route.

Request (dev instance, 61 upcoming appointments, 12 users) before after
admin, unpaged list 691 65
admin, limit=20 263 65
admin, timeframe=all&onlyForMe=true 1132 65
member, unpaged list 694 66
member, limit=20 264 66
single appointment 60–62 55–58

Verified byte-identical responses (JSON with sorted keys) for admin and member, list and single appointment, before and after.

Notification waves: read the appointment once

The notifier looked the appointment up for every recipient of a wave just to decide whether "Maybe" is offered, and resolved the icon URL each time. Both are remembered per request. With 80 recipients, creating an appointment drops from 429 to 353 queries, moving one from 351 to 272. The remaining per-recipient cost is Nextcloud's own (notification insert, user lookups, push signing) plus the push proxy round trip.

Cache lifetime

All new caches are plain fields on request-scoped services (VisibilityService, Notifier): they live for one web request, one cron run or one occ command — the same lifetime the existing team-member and permission caches have. Nothing is written to Redis or APCu.

Follow-ups, deliberately out of scope

  • Appointments with an attendee limit still read their queue two to three times per row (occupancy(), standingOf(), confirmedIds()); CapacityService::split() already offers the pre-loaded seam.
  • getUpcomingAppointmentsForWidget() (fixed five rows) still reads per row.
  • AppointmentService::resolveAudience()/getAllWhitelistedUsers(), PermissionService::collectGroupMembers() and StatisticsService::groupMembers() still expand groups through the group manager instead of VisibilityService::getGroupMembers().
  • The response counts and the check-in tally walk the same audience twice per row (CPU only).

Checks

./scripts/check.sh is green (eslint, stylelint, php-cs-fixer, psalm, phpunit, vite build, l10n, OpenAPI spec unchanged). The psalm baseline loses the entries that the now-typed AppointmentAttachmentMapper::findByAppointment() return fixes.

🤖 Generated with Claude Code

luflow added 2 commits October 7, 2026 22:45
A wave prepares one notification per recipient, and the notifier looked
the appointment up for every one of them just to decide whether "Maybe"
is offered — 80 identical queries for 80 recipients — and resolved the
app icon URL each time. Both are now remembered per request.

Dev instance, 80 recipients: creating an appointment drops from 429 to
353 queries, moving one from 351 to 272.
The list endpoint issued ten-plus queries per appointment: the response
summary re-read the appointment and its responses, resolved the section
groups' members anew for every row (the group manager drops a group's
cached members whenever getUserGroups() hands out a fresh group object,
which it does once per responder), the attachments and the series size
were read per row, the check-in counts re-read appointment and responses
once more from the controller, and the scheduling filters read every
row's responses before paging.

Now the page reads everybody's responses, attachments, series sizes and
bookings once each, hands the entity and its responses to the summary
and check-in tallies, and VisibilityService keeps group members, the
group list and "everyone" for the request. The single-appointment
endpoint takes the same route. Payloads are unchanged — verified
byte-identical on the dev instance for admin and member views.

Dev instance (61 upcoming appointments, 12 users): the unpaged list
drops from 691 to 65 queries, a 20-row page from 263 to 65, the "all,
only mine" view from 1132 to 65, the single appointment from 60 to 56.

The psalm baseline loses the entries that the now-typed
AppointmentAttachmentMapper::findByAppointment() return fixes.
@luflow
luflow merged commit 321fd8e into main Oct 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant