001/*- 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2023 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.search.cache; 021 022import ca.uhn.fhir.i18n.Msg; 023import ca.uhn.fhir.interceptor.model.RequestPartitionId; 024import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; 025import ca.uhn.fhir.jpa.dao.data.ISearchDao; 026import ca.uhn.fhir.jpa.dao.data.ISearchIncludeDao; 027import ca.uhn.fhir.jpa.dao.data.ISearchResultDao; 028import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; 029import ca.uhn.fhir.jpa.dao.tx.IHapiTransactionService; 030import ca.uhn.fhir.jpa.entity.Search; 031import ca.uhn.fhir.jpa.model.search.SearchStatusEnum; 032import ca.uhn.fhir.system.HapiSystemProperties; 033import com.google.common.annotations.VisibleForTesting; 034import com.google.common.collect.Lists; 035import org.apache.commons.lang3.Validate; 036import org.apache.commons.lang3.time.DateUtils; 037import org.hl7.fhir.dstu3.model.InstantType; 038import org.slf4j.Logger; 039import org.slf4j.LoggerFactory; 040import org.springframework.beans.factory.annotation.Autowired; 041import org.springframework.data.domain.PageRequest; 042import org.springframework.data.domain.Slice; 043import org.springframework.transaction.annotation.Propagation; 044import org.springframework.transaction.annotation.Transactional; 045 046import java.time.Instant; 047import java.util.Collection; 048import java.util.Date; 049import java.util.List; 050import java.util.Optional; 051 052public class DatabaseSearchCacheSvcImpl implements ISearchCacheSvc { 053 /* 054 * Be careful increasing this number! We use the number of params here in a 055 * DELETE FROM foo WHERE params IN (term,term,term...) 056 * type query and this can fail if we have 1000s of params 057 */ 058 public static final int DEFAULT_MAX_RESULTS_TO_DELETE_IN_ONE_STMT = 500; 059 public static final int DEFAULT_MAX_RESULTS_TO_DELETE_IN_ONE_PAS = 20000; 060 public static final long SEARCH_CLEANUP_JOB_INTERVAL_MILLIS = DateUtils.MILLIS_PER_MINUTE; 061 public static final int DEFAULT_MAX_DELETE_CANDIDATES_TO_FIND = 2000; 062 private static final Logger ourLog = LoggerFactory.getLogger(DatabaseSearchCacheSvcImpl.class); 063 private static int ourMaximumResultsToDeleteInOneStatement = DEFAULT_MAX_RESULTS_TO_DELETE_IN_ONE_STMT; 064 private static int ourMaximumResultsToDeleteInOnePass = DEFAULT_MAX_RESULTS_TO_DELETE_IN_ONE_PAS; 065 private static int ourMaximumSearchesToCheckForDeletionCandidacy = DEFAULT_MAX_DELETE_CANDIDATES_TO_FIND; 066 private static Long ourNowForUnitTests; 067 /* 068 * We give a bit of extra leeway just to avoid race conditions where a query result 069 * is being reused (because a new client request came in with the same params) right before 070 * the result is to be deleted 071 */ 072 private long myCutoffSlack = SEARCH_CLEANUP_JOB_INTERVAL_MILLIS; 073 074 @Autowired 075 private ISearchDao mySearchDao; 076 077 @Autowired 078 private ISearchResultDao mySearchResultDao; 079 080 @Autowired 081 private ISearchIncludeDao mySearchIncludeDao; 082 083 @Autowired 084 private IHapiTransactionService myTransactionService; 085 086 @Autowired 087 private JpaStorageSettings myStorageSettings; 088 089 @VisibleForTesting 090 public void setCutoffSlackForUnitTest(long theCutoffSlack) { 091 myCutoffSlack = theCutoffSlack; 092 } 093 094 @Override 095 public Search save(Search theSearch, RequestPartitionId theRequestPartitionId) { 096 return myTransactionService 097 .withSystemRequestOnPartition(theRequestPartitionId) 098 .execute(() -> mySearchDao.save(theSearch)); 099 } 100 101 @Override 102 @Transactional(propagation = Propagation.REQUIRED) 103 public Optional<Search> fetchByUuid(String theUuid, RequestPartitionId theRequestPartitionId) { 104 Validate.notBlank(theUuid); 105 return myTransactionService 106 .withSystemRequestOnPartition(theRequestPartitionId) 107 .execute(() -> mySearchDao.findByUuidAndFetchIncludes(theUuid)); 108 } 109 110 void setSearchDaoForUnitTest(ISearchDao theSearchDao) { 111 mySearchDao = theSearchDao; 112 } 113 114 void setTransactionServiceForUnitTest(IHapiTransactionService theTransactionService) { 115 myTransactionService = theTransactionService; 116 } 117 118 @Override 119 public Optional<Search> tryToMarkSearchAsInProgress(Search theSearch, RequestPartitionId theRequestPartitionId) { 120 ourLog.trace( 121 "Going to try to change search status from {} to {}", theSearch.getStatus(), SearchStatusEnum.LOADING); 122 try { 123 124 return myTransactionService 125 .withSystemRequest() 126 .withRequestPartitionId(theRequestPartitionId) 127 .withPropagation(Propagation.REQUIRES_NEW) 128 .execute(t -> { 129 Search search = mySearchDao.findById(theSearch.getId()).orElse(theSearch); 130 131 if (search.getStatus() != SearchStatusEnum.PASSCMPLET) { 132 throw new IllegalStateException( 133 Msg.code(1167) + "Can't change to LOADING because state is " + search.getStatus()); 134 } 135 search.setStatus(SearchStatusEnum.LOADING); 136 Search newSearch = mySearchDao.save(search); 137 return Optional.of(newSearch); 138 }); 139 } catch (Exception e) { 140 ourLog.warn("Failed to activate search: {}", e.toString()); 141 ourLog.trace("Failed to activate search", e); 142 return Optional.empty(); 143 } 144 } 145 146 @Override 147 public Optional<Search> findCandidatesForReuse( 148 String theResourceType, 149 String theQueryString, 150 Instant theCreatedAfter, 151 RequestPartitionId theRequestPartitionId) { 152 HapiTransactionService.requireTransaction(); 153 154 String queryString = Search.createSearchQueryStringForStorage(theQueryString, theRequestPartitionId); 155 156 int hashCode = queryString.hashCode(); 157 Collection<Search> candidates = 158 mySearchDao.findWithCutoffOrExpiry(theResourceType, hashCode, Date.from(theCreatedAfter)); 159 160 for (Search nextCandidateSearch : candidates) { 161 // We should only reuse our search if it was created within the permitted window 162 // Date.after() is unreliable. Instant.isAfter() always works. 163 if (queryString.equals(nextCandidateSearch.getSearchQueryString()) 164 && nextCandidateSearch.getCreated().toInstant().isAfter(theCreatedAfter)) { 165 return Optional.of(nextCandidateSearch); 166 } 167 } 168 169 return Optional.empty(); 170 } 171 172 @Override 173 public void pollForStaleSearchesAndDeleteThem(RequestPartitionId theRequestPartitionId) { 174 HapiTransactionService.noTransactionAllowed(); 175 176 if (!myStorageSettings.isExpireSearchResults()) { 177 return; 178 } 179 180 long cutoffMillis = myStorageSettings.getExpireSearchResultsAfterMillis(); 181 if (myStorageSettings.getReuseCachedSearchResultsForMillis() != null) { 182 cutoffMillis = cutoffMillis + myStorageSettings.getReuseCachedSearchResultsForMillis(); 183 } 184 final Date cutoff = new Date((now() - cutoffMillis) - myCutoffSlack); 185 186 if (ourNowForUnitTests != null) { 187 ourLog.info( 188 "Searching for searches which are before {} - now is {}", 189 new InstantType(cutoff), 190 new InstantType(new Date(now()))); 191 } 192 193 ourLog.debug("Searching for searches which are before {}", cutoff); 194 195 // Mark searches as deleted if they should be 196 final Slice<Long> toMarkDeleted = myTransactionService 197 .withSystemRequestOnPartition(theRequestPartitionId) 198 .execute(theStatus -> mySearchDao.findWhereCreatedBefore( 199 cutoff, new Date(), PageRequest.of(0, ourMaximumSearchesToCheckForDeletionCandidacy))); 200 assert toMarkDeleted != null; 201 for (final Long nextSearchToDelete : toMarkDeleted) { 202 ourLog.debug("Deleting search with PID {}", nextSearchToDelete); 203 myTransactionService 204 .withSystemRequest() 205 .withRequestPartitionId(theRequestPartitionId) 206 .execute(t -> { 207 mySearchDao.updateDeleted(nextSearchToDelete, true); 208 return null; 209 }); 210 } 211 212 // Delete searches that are marked as deleted 213 final Slice<Long> toDelete = myTransactionService 214 .withSystemRequestOnPartition(theRequestPartitionId) 215 .execute(theStatus -> 216 mySearchDao.findDeleted(PageRequest.of(0, ourMaximumSearchesToCheckForDeletionCandidacy))); 217 assert toDelete != null; 218 for (final Long nextSearchToDelete : toDelete) { 219 ourLog.debug("Deleting search with PID {}", nextSearchToDelete); 220 myTransactionService 221 .withSystemRequest() 222 .withRequestPartitionId(theRequestPartitionId) 223 .execute(t -> { 224 deleteSearch(nextSearchToDelete); 225 return null; 226 }); 227 } 228 229 int count = toDelete.getContent().size(); 230 if (count > 0) { 231 if (ourLog.isDebugEnabled() || HapiSystemProperties.isTestModeEnabled()) { 232 Long total = myTransactionService 233 .withSystemRequest() 234 .withRequestPartitionId(theRequestPartitionId) 235 .execute(t -> mySearchDao.count()); 236 ourLog.debug("Deleted {} searches, {} remaining", count, total); 237 } 238 } 239 } 240 241 private void deleteSearch(final Long theSearchPid) { 242 mySearchDao.findById(theSearchPid).ifPresent(searchToDelete -> { 243 mySearchIncludeDao.deleteForSearch(searchToDelete.getId()); 244 245 /* 246 * Note, we're only deleting up to 500 results in an individual search here. This 247 * is to prevent really long running transactions in cases where there are 248 * huge searches with tons of results in them. By the time we've gotten here 249 * we have marked the parent Search entity as deleted, so it's not such a 250 * huge deal to be only partially deleting search results. They'll get deleted 251 * eventually 252 */ 253 int max = ourMaximumResultsToDeleteInOnePass; 254 Slice<Long> resultPids = mySearchResultDao.findForSearch(PageRequest.of(0, max), searchToDelete.getId()); 255 if (resultPids.hasContent()) { 256 List<List<Long>> partitions = 257 Lists.partition(resultPids.getContent(), ourMaximumResultsToDeleteInOneStatement); 258 for (List<Long> nextPartition : partitions) { 259 mySearchResultDao.deleteByIds(nextPartition); 260 } 261 } 262 263 // Only delete if we don't have results left in this search 264 if (resultPids.getNumberOfElements() < max) { 265 ourLog.debug( 266 "Deleting search {}/{} - Created[{}]", 267 searchToDelete.getId(), 268 searchToDelete.getUuid(), 269 new InstantType(searchToDelete.getCreated())); 270 mySearchDao.deleteByPid(searchToDelete.getId()); 271 } else { 272 ourLog.debug( 273 "Purged {} search results for deleted search {}/{}", 274 resultPids.getSize(), 275 searchToDelete.getId(), 276 searchToDelete.getUuid()); 277 } 278 }); 279 } 280 281 @VisibleForTesting 282 public static void setMaximumSearchesToCheckForDeletionCandidacyForUnitTest( 283 int theMaximumSearchesToCheckForDeletionCandidacy) { 284 ourMaximumSearchesToCheckForDeletionCandidacy = theMaximumSearchesToCheckForDeletionCandidacy; 285 } 286 287 @VisibleForTesting 288 public static void setMaximumResultsToDeleteInOnePassForUnitTest(int theMaximumResultsToDeleteInOnePass) { 289 ourMaximumResultsToDeleteInOnePass = theMaximumResultsToDeleteInOnePass; 290 } 291 292 @VisibleForTesting 293 public static void setMaximumResultsToDeleteForUnitTest(int theMaximumResultsToDelete) { 294 ourMaximumResultsToDeleteInOneStatement = theMaximumResultsToDelete; 295 } 296 297 /** 298 * This is for unit tests only, do not call otherwise 299 */ 300 @VisibleForTesting 301 public static void setNowForUnitTests(Long theNowForUnitTests) { 302 ourNowForUnitTests = theNowForUnitTests; 303 } 304 305 private static long now() { 306 if (ourNowForUnitTests != null) { 307 return ourNowForUnitTests; 308 } 309 return System.currentTimeMillis(); 310 } 311}