#835: BirthdayCache.kt

projectforge-business/src/main/kotlin/org/projectforge/business/address/BirthdayCache.kt Kotlin cache class — cached index of all address book birthdays for fast date-range queries. Extends AbstractCache for automatic TTL-based refresh. Location: projectforge-business/src/main/kotlin/org/projectforge/business/address/BirthdayCache.kt 124 lines · 81 code · 33 comments · 10 blank
Purpose: The calendar dashboard and birthday notification system need to answer "whose birthday is it this week/month?" efficiently. Without a cache, each query would scan the entire address table. BirthdayCache loads all non-deleted addresses with a birthday date into memory once and performs in-memory date-range filtering, achieving O(1) query cost after initial load.

Architecture

Inheritance Chain

BirthdayCache extends AbstractCache (from org.projectforge.framework.cache), ProjectForge's base class for TTL-based caches. This gives it:
checkRefresh() — automatically calls refresh() if TTL has expired
setExpireInMinutes() — configurable TTL
• Thread-safe registration in PfCaches (#820)
• Participation in SystemService.refreshCaches() bulk refresh

Singleton Guard

The cache uses a companion object with a lateinit var instance and an instanceCounter guard. If Spring (or test code) creates a second instance, a warning is logged. This prevents the AbstractCache base class from double-registering the cache in PfCaches, which would cause duplicate refresh calls.

Data Model

The cache stores a MutableList<BirthdayAddress> where each BirthdayAddress wraps an AddressDO and implements Comparable. The compare-to orders by birthday month/day first, then by name — enabling the TreeSet used in getBirthdays() to produce naturally sorted output without explicit sorting calls.

Query Algorithm

getBirthdays(fromDate, toDate, all, favorites) filters the cached list with four guard conditions applied in this priority order for efficiency:

1. Access control (addressDao.hasLoggedInUserSelectAccess): Skips addresses the current user cannot see. This is essential because the cache loads ALL addresses (using checkAccess = false in the DAO query).
2. Favorites filter (!all && !favorites.contains(address.id)): If not in "all" mode, only addresses in the user's favorites list are included.
3. Birthday availability: Skips addresses where PFDateTime.fromOrNull(address.birthday) returns null.
4. Date range check (DateHelper.dateOfYearBetween): Compares month/day (ignoring year) against the range. This handles year-spanning ranges (e.g., Dec 15 — Jan 15).

Results are collected in a TreeSet<BirthdayAddress> for automatic sorting.

Refresh Logic

refresh() runs inside persistenceService.runIsolatedReadOnly { } — a dedicated transaction context separate from any active HTTP request transaction. This isolation prevents:
• Contention with request-scoped Hibernate sessions
• Lazy-loading issues (all data is fetched eagerly)
• Read phenomena from uncommitted writes in the request transaction

The query uses QueryFilter.isNotNull("birthday") to exclude addresses without a birthday date (which are the majority). The deleted = false filter ensures soft-deleted addresses are excluded.

An additional defensive check (if (it.deleted != true)) guards against entities where the JPA filter might not have been applied (e.g., during migration or if the filter configuration changes).

Git History

868d6abb7 2025 → 2026 (copyright year update)
b131193e7 Member variables refactored by using by lazy {} (BirthdayCache among others)
63081666f Source file headers: 2024→2025
0ceceb28f All cache refreshes are now running in isolated transactions or contexts
1b50060c3 BaseDao renamed: get→find, save→insert, getList→select, load→select
87aaf6a5a BaseDao refactored (internal* methods renamed)
4c04cfd65 MAJOR: Migration of integer id's to Long id's (including FK cascades)
b6092df09 Copyright 2023 → 2024
ab45d51fa Copyright 2001-2022 → 2001-2023
cf80afb28 Tenancy functionality removed (system tested)
c0f2b9de0 Tenants functionality removed everywhere
b209e00ba PFDay/PFDateTime.from → from, fromOrNow, fromOrNull
24019a0f5 Eliminate DateHolder occurrences
bfa336a64 BirthdayCache now refreshed by SystemService.refreshCaches(), ignores deleted users
76a8fb69d Hibernate.Restrictions → PF.QueryFilter
140cb4c40 WIP: Birthdays cache (initial implementation)
615c248f4 Performance fix: PersonalAddressDao.getList caused slowdowns; BirthdayCache and PersonalAddressDao.getIdList introduced

Key Commits Explained

615c248f4 (earliest) — Performance fix: The cache was created to solve a critical performance issue. The PersonalAddressDao.getList was taking seconds to execute, and displaying birthdays in the calendar view caused noticeable latency. The cache eliminated the per-request address table scan.

0ceceb28f — Isolated transactions: Refresh operations moved from the request transaction to runIsolatedReadOnly. This fixed a class of bugs where a long-running refresh inside a request transaction could cause Hibernate session timeouts or stale data visibility.

4c04cfd65 — int→Long migration: All ID types changed from Integer to Long, affecting the favorites: List<Long> parameter and address.id comparisons throughout the filtering logic.

cf80afb28 — Tenancy removal: The multi-tenant feature was removed, simplifying the cache (no tenant isolation needed).

bfa336a64 — Deleted-user fix: Added the explicit check for birthday of deleted users, ensuring they don't appear in dashboard widgets.

Performance Characteristics

OperationTime complexityNotes
Refresh (cache load)O(n) where n = addresses with birthdaySingle SQL query, runs in isolated transaction
getBirthdays() (date range)O(n) scan of cached listIn-memory; typically n < 500 for SME
getBirthdays() (favorites only)O(n) scan with favorites.contains() = O(1) per elementfavorites list passed as parameter (already loaded)
Access check per entryO(1) per entry (cached group memberships)AddressDao.hasLoggedInUserSelectAccess
The linear scan is acceptable because the cache only contains addresses with birthdays (not the full address book), and the access/favorites filtering avoids expensive per-entry DB lookups by working entirely in memory with pre-loaded security data.
Gotcha — Access control bypass in refresh: The refresh query uses checkAccess = false to load ALL addresses. Access control is applied later in getBirthdays() per-query. This means the cache itself holds data the current user might not be allowed to see. Each getBirthdays() call re-evaluates access based on the current logged-in user, making the cache user-agnostic at storage time but user-aware at query time. If a user's access is revoked, the next birthday query (not the next cache refresh) will exclude their entries.