Batch API key usage queries to avoid N+1

This commit is contained in:
Anthony Stirling
2026-07-10 13:02:09 +01:00
parent e67bb77a59
commit 0a7cd1185d
3 changed files with 58 additions and 7 deletions
@@ -1,5 +1,6 @@
package stirling.software.proprietary.security.repository;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -33,6 +34,21 @@ public interface ApiKeyDailyUsageRepository
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
Long countForDay(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
/** Batched today-count for many keys in one query (avoids N+1 when listing keys). */
@Query(
"SELECT u.apiKeyId AS apiKeyId, u.count AS total FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId IN :ids AND u.epochDay = :epochDay")
List<ApiKeyUsageSum> countForDayByIds(
@Param("ids") Collection<Long> ids, @Param("epochDay") long epochDay);
/** Batched trailing-window sum for many keys in one query. */
@Query(
"SELECT u.apiKeyId AS apiKeyId, SUM(u.count) AS total FROM ApiKeyDailyUsage u "
+ "WHERE u.apiKeyId IN :ids AND u.epochDay >= :fromDayInclusive "
+ "GROUP BY u.apiKeyId")
List<ApiKeyUsageSum> sumSinceByIds(
@Param("ids") Collection<Long> ids, @Param("fromDayInclusive") long fromDayInclusive);
void deleteByApiKeyId(Long apiKeyId);
List<ApiKeyDailyUsage> findByApiKeyId(Long apiKeyId);
@@ -0,0 +1,8 @@
package stirling.software.proprietary.security.repository;
/** Projection: a key id and a usage total, for batching per-key usage into one query. */
public interface ApiKeyUsageSum {
Long getApiKeyId();
Long getTotal();
}
@@ -4,8 +4,10 @@ import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
@@ -86,8 +88,31 @@ public class ApiKeyManagementService {
.forEach(visible::add);
}
// Batch usage for all visible keys into two queries rather than two-per-key (avoids N+1).
long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
List<Long> ids = visible.stream().map(ApiKey::getId).toList();
Map<Long, Long> todayById = new HashMap<>();
Map<Long, Long> monthById = new HashMap<>();
if (!ids.isEmpty()) {
usageRepository
.countForDayByIds(ids, today)
.forEach(r -> todayById.put(r.getApiKeyId(), r.getTotal()));
usageRepository
.sumSinceByIds(ids, today - (MONTH_WINDOW_DAYS - 1))
.forEach(r -> monthById.put(r.getApiKeyId(), r.getTotal()));
}
List<PortalApiKeyDto> keys =
visible.stream().map(k -> toDto(caller, k, isManager, teamName)).toList();
visible.stream()
.map(
k ->
toDto(
caller,
k,
teamName,
zeroIfNull(todayById.get(k.getId())),
zeroIfNull(monthById.get(k.getId()))))
.toList();
return PortalApiKeysResponse.builder()
.keys(keys)
.canCreateTeamKeys(isManager && teamId != null)
@@ -95,6 +120,10 @@ public class ApiKeyManagementService {
.build();
}
private static long zeroIfNull(Long value) {
return value == null ? 0L : value;
}
/** Create a key and return its one-time secret. */
@Transactional
public CreatedApiKeyDto createKey(CreateApiKeyRequest request) {
@@ -151,7 +180,7 @@ public class ApiKeyManagementService {
.build());
return CreatedApiKeyDto.builder()
.key(toDto(caller, saved, policyAuthority.canEditPolicies(), teamName))
.key(toDto(caller, saved, teamName, 0L, 0L))
.secret(rawKey)
.build();
}
@@ -230,10 +259,8 @@ public class ApiKeyManagementService {
});
}
private PortalApiKeyDto toDto(User caller, ApiKey key, boolean isManager, String teamName) {
long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
Long todayCount = usageRepository.countForDay(key.getId(), today);
long usageMonth = usageRepository.sumSince(key.getId(), today - (MONTH_WINDOW_DAYS - 1));
private PortalApiKeyDto toDto(
User caller, ApiKey key, String teamName, long usageToday, long usageMonth) {
return PortalApiKeyDto.builder()
.id(String.valueOf(key.getId()))
.name(key.getName())
@@ -247,7 +274,7 @@ public class ApiKeyManagementService {
? "Never"
: LAST_USED_FORMAT.format(key.getLastUsedAt()))
.status(key.isActive() ? "active" : "revoked")
.usageToday(todayCount == null ? 0 : todayCount)
.usageToday(usageToday)
.usageMonth(usageMonth)
.canManage(canManage(caller, key))
.build();