
001/*- 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2025 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%family 019 */ 020package ca.uhn.fhir.jpa.search; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.i18n.Msg; 024import ca.uhn.fhir.interceptor.api.HookParams; 025import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 026import ca.uhn.fhir.interceptor.api.Pointcut; 027import ca.uhn.fhir.interceptor.model.RequestPartitionId; 028import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; 029import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 030import ca.uhn.fhir.jpa.api.dao.IDao; 031import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 032import ca.uhn.fhir.jpa.api.svc.ISearchCoordinatorSvc; 033import ca.uhn.fhir.jpa.config.SearchConfig; 034import ca.uhn.fhir.jpa.dao.BaseStorageDao; 035import ca.uhn.fhir.jpa.dao.ISearchBuilder; 036import ca.uhn.fhir.jpa.dao.NonPersistedSearch; 037import ca.uhn.fhir.jpa.dao.SearchBuilderFactory; 038import ca.uhn.fhir.jpa.dao.search.ResourceNotFoundInIndexException; 039import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; 040import ca.uhn.fhir.jpa.entity.Search; 041import ca.uhn.fhir.jpa.model.dao.JpaPid; 042import ca.uhn.fhir.jpa.model.search.SearchStatusEnum; 043import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; 044import ca.uhn.fhir.jpa.search.builder.StorageInterceptorHooksFacade; 045import ca.uhn.fhir.jpa.search.builder.tasks.SearchContinuationTask; 046import ca.uhn.fhir.jpa.search.builder.tasks.SearchTask; 047import ca.uhn.fhir.jpa.search.builder.tasks.SearchTaskParameters; 048import ca.uhn.fhir.jpa.search.cache.ISearchCacheSvc; 049import ca.uhn.fhir.jpa.search.cache.ISearchResultCacheSvc; 050import ca.uhn.fhir.jpa.search.cache.SearchCacheStatusEnum; 051import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 052import ca.uhn.fhir.jpa.util.QueryParameterUtils; 053import ca.uhn.fhir.model.api.IQueryParameterType; 054import ca.uhn.fhir.model.api.Include; 055import ca.uhn.fhir.rest.api.CacheControlDirective; 056import ca.uhn.fhir.rest.api.Constants; 057import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; 058import ca.uhn.fhir.rest.api.SearchTotalModeEnum; 059import ca.uhn.fhir.rest.api.server.IBundleProvider; 060import ca.uhn.fhir.rest.api.server.RequestDetails; 061import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 062import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 063import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 064import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 065import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster; 066import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; 067import ca.uhn.fhir.util.AsyncUtil; 068import ca.uhn.fhir.util.StopWatch; 069import ca.uhn.fhir.util.UrlUtil; 070import com.google.common.annotations.VisibleForTesting; 071import jakarta.annotation.Nonnull; 072import jakarta.annotation.Nullable; 073import org.apache.commons.lang3.time.DateUtils; 074import org.hl7.fhir.instance.model.api.IBaseResource; 075import org.springframework.beans.factory.BeanFactory; 076import org.springframework.data.domain.PageRequest; 077import org.springframework.data.domain.Pageable; 078import org.springframework.data.domain.Sort; 079import org.springframework.stereotype.Component; 080import org.springframework.transaction.support.TransactionSynchronizationManager; 081 082import java.io.Serial; 083import java.time.Instant; 084import java.time.temporal.ChronoUnit; 085import java.util.Date; 086import java.util.HashSet; 087import java.util.List; 088import java.util.Optional; 089import java.util.Set; 090import java.util.UUID; 091import java.util.concurrent.Callable; 092import java.util.concurrent.ConcurrentHashMap; 093import java.util.concurrent.TimeUnit; 094import java.util.function.Consumer; 095import java.util.stream.Collectors; 096 097import static ca.uhn.fhir.jpa.util.QueryParameterUtils.DEFAULT_SYNC_SIZE; 098import static org.apache.commons.lang3.StringUtils.isBlank; 099import static org.apache.commons.lang3.StringUtils.isNotBlank; 100 101@Component("mySearchCoordinatorSvc") 102public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc<JpaPid> { 103 104 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(SearchCoordinatorSvcImpl.class); 105 public static final int SEARCH_EXPIRY_OFFSET_MINUTES = 10; 106 107 private final FhirContext myContext; 108 private final JpaStorageSettings myStorageSettings; 109 private final IInterceptorBroadcaster myInterceptorBroadcaster; 110 private final HapiTransactionService myTxService; 111 private final ISearchCacheSvc mySearchCacheSvc; 112 private final ISearchResultCacheSvc mySearchResultCacheSvc; 113 private final DaoRegistry myDaoRegistry; 114 private final SearchBuilderFactory<JpaPid> mySearchBuilderFactory; 115 private final ISynchronousSearchSvc mySynchronousSearchSvc; 116 private final PersistedJpaBundleProviderFactory myPersistedJpaBundleProviderFactory; 117 private final ISearchParamRegistry mySearchParamRegistry; 118 private final SearchStrategyFactory mySearchStrategyFactory; 119 private final ExceptionService myExceptionSvc; 120 private final BeanFactory myBeanFactory; 121 private final IRequestPartitionHelperSvc myRequestPartitionHelperSvc; 122 private ConcurrentHashMap<String, SearchTask> myIdToSearchTask = new ConcurrentHashMap<>(); 123 124 private final Consumer<String> myOnRemoveSearchTask = myIdToSearchTask::remove; 125 126 private final StorageInterceptorHooksFacade myStorageInterceptorHooks; 127 private Integer myLoadingThrottleForUnitTests = null; 128 private long myMaxMillisToWaitForRemoteResults = DateUtils.MILLIS_PER_MINUTE; 129 private boolean myNeverUseLocalSearchForUnitTests; 130 private int mySyncSize = DEFAULT_SYNC_SIZE; 131 132 /** 133 * Constructor 134 */ 135 public SearchCoordinatorSvcImpl( 136 FhirContext theContext, 137 JpaStorageSettings theStorageSettings, 138 IInterceptorBroadcaster theInterceptorBroadcaster, 139 HapiTransactionService theTxService, 140 ISearchCacheSvc theSearchCacheSvc, 141 ISearchResultCacheSvc theSearchResultCacheSvc, 142 DaoRegistry theDaoRegistry, 143 SearchBuilderFactory<JpaPid> theSearchBuilderFactory, 144 ISynchronousSearchSvc theSynchronousSearchSvc, 145 PersistedJpaBundleProviderFactory thePersistedJpaBundleProviderFactory, 146 ISearchParamRegistry theSearchParamRegistry, 147 SearchStrategyFactory theSearchStrategyFactory, 148 ExceptionService theExceptionSvc, 149 BeanFactory theBeanFactory, 150 IRequestPartitionHelperSvc theRequestPartitionHelperSvc) { 151 super(); 152 myContext = theContext; 153 myStorageSettings = theStorageSettings; 154 myInterceptorBroadcaster = theInterceptorBroadcaster; 155 myTxService = theTxService; 156 mySearchCacheSvc = theSearchCacheSvc; 157 mySearchResultCacheSvc = theSearchResultCacheSvc; 158 myDaoRegistry = theDaoRegistry; 159 mySearchBuilderFactory = theSearchBuilderFactory; 160 mySynchronousSearchSvc = theSynchronousSearchSvc; 161 myPersistedJpaBundleProviderFactory = thePersistedJpaBundleProviderFactory; 162 mySearchParamRegistry = theSearchParamRegistry; 163 mySearchStrategyFactory = theSearchStrategyFactory; 164 myExceptionSvc = theExceptionSvc; 165 myBeanFactory = theBeanFactory; 166 myRequestPartitionHelperSvc = theRequestPartitionHelperSvc; 167 168 myStorageInterceptorHooks = new StorageInterceptorHooksFacade(myInterceptorBroadcaster); 169 } 170 171 @VisibleForTesting 172 Set<String> getActiveSearchIds() { 173 return myIdToSearchTask.keySet(); 174 } 175 176 @VisibleForTesting 177 public void setIdToSearchTaskMapForUnitTests(ConcurrentHashMap<String, SearchTask> theIdToSearchTaskMap) { 178 myIdToSearchTask = theIdToSearchTaskMap; 179 } 180 181 @VisibleForTesting 182 public void setLoadingThrottleForUnitTests(Integer theLoadingThrottleForUnitTests) { 183 myLoadingThrottleForUnitTests = theLoadingThrottleForUnitTests; 184 } 185 186 @VisibleForTesting 187 public void setNeverUseLocalSearchForUnitTests(boolean theNeverUseLocalSearchForUnitTests) { 188 myNeverUseLocalSearchForUnitTests = theNeverUseLocalSearchForUnitTests; 189 } 190 191 @VisibleForTesting 192 public void setSyncSizeForUnitTests(int theSyncSize) { 193 mySyncSize = theSyncSize; 194 } 195 196 @Override 197 public void cancelAllActiveSearches() { 198 for (SearchTask next : myIdToSearchTask.values()) { 199 ourLog.info( 200 "Requesting immediate abort of search: {}", next.getSearch().getUuid()); 201 next.requestImmediateAbort(); 202 AsyncUtil.awaitLatchAndIgnoreInterrupt(next.getCompletionLatch(), 30, TimeUnit.SECONDS); 203 } 204 } 205 206 @SuppressWarnings("SameParameterValue") 207 @VisibleForTesting 208 public void setMaxMillisToWaitForRemoteResultsForUnitTest(long theMaxMillisToWaitForRemoteResults) { 209 myMaxMillisToWaitForRemoteResults = theMaxMillisToWaitForRemoteResults; 210 } 211 212 /** 213 * This method is called by the HTTP client processing thread in order to 214 * fetch resources. 215 * <p> 216 * This method must not be called from inside a transaction. The rationale is that 217 * the {@link Search} entity is treated as a piece of shared state across client threads 218 * accessing the same thread, so we need to be able to update that table in a transaction 219 * and commit it right away in order for that to work. Examples of needing to do this 220 * include if two different clients request the same search and are both paging at the 221 * same time, but also includes clients that are hacking the paging links to 222 * fetch multiple pages of a search result in parallel. In both cases we need to only 223 * let one of them actually activate the search, or we will have conflicts. The other thread 224 * just needs to wait until the first one actually fetches more results. 225 */ 226 @Override 227 public List<JpaPid> getResources( 228 final String theUuid, 229 int theFrom, 230 int theTo, 231 @Nullable RequestDetails theRequestDetails, 232 RequestPartitionId theRequestPartitionId) { 233 assert !TransactionSynchronizationManager.isActualTransactionActive(); 234 235 // If we're actively searching right now, don't try to do anything until at least one batch has been 236 // persisted in the DB 237 SearchTask searchTask = myIdToSearchTask.get(theUuid); 238 if (searchTask != null) { 239 searchTask.awaitInitialSync(); 240 } 241 242 ourLog.trace("About to start looking for resources {}-{}", theFrom, theTo); 243 244 Search search; 245 StopWatch sw = new StopWatch(); 246 while (true) { 247 248 if (!myNeverUseLocalSearchForUnitTests) { 249 if (searchTask != null) { 250 ourLog.trace("Local search found"); 251 List<JpaPid> resourcePids = searchTask.getResourcePids(theFrom, theTo); 252 ourLog.trace( 253 "Local search returned {} pids, wanted {}-{} - Search: {}", 254 resourcePids.size(), 255 theFrom, 256 theTo, 257 searchTask.getSearch()); 258 259 /* 260 * Generally, if a search task is open, the fastest possible thing is to just return its results. This 261 * will work most of the time, but can fail if the task hit a search threshold and the client is requesting 262 * results beyond that threashold. In that case, we'll keep going below, since that will trigger another 263 * task. 264 */ 265 if ((searchTask.getSearch().getNumFound() 266 - searchTask.getSearch().getNumBlocked()) 267 >= theTo 268 || resourcePids.size() == (theTo - theFrom)) { 269 return resourcePids; 270 } 271 } 272 } 273 274 Callable<Search> searchCallback = () -> mySearchCacheSvc 275 .fetchByUuid(theUuid, theRequestPartitionId) 276 .orElseThrow(() -> myExceptionSvc.newUnknownSearchException(theUuid)); 277 search = myTxService 278 .withRequest(theRequestDetails) 279 .withRequestPartitionId(theRequestPartitionId) 280 .execute(searchCallback); 281 QueryParameterUtils.verifySearchHasntFailedOrThrowInternalErrorException(search); 282 283 if (search.getStatus() == SearchStatusEnum.FINISHED) { 284 ourLog.trace("Search entity marked as finished with {} results", search.getNumFound()); 285 break; 286 } 287 if ((search.getNumFound() - search.getNumBlocked()) >= theTo) { 288 ourLog.trace("Search entity has {} results so far", search.getNumFound()); 289 break; 290 } 291 292 if (sw.getMillis() > myMaxMillisToWaitForRemoteResults) { 293 ourLog.error( 294 "Search {} of type {} for {}{} timed out after {}ms with status {}.", 295 search.getId(), 296 search.getSearchType(), 297 search.getResourceType(), 298 search.getSearchQueryString(), 299 sw.getMillis(), 300 search.getStatus()); 301 throw new InternalErrorException(Msg.code(1163) + "Request timed out after " + sw.getMillis() + "ms"); 302 } 303 304 // If the search was saved in "pass complete mode" it's probably time to 305 // start a new pass 306 if (search.getStatus() == SearchStatusEnum.PASSCMPLET) { 307 ourLog.trace("Going to try to start next search"); 308 Optional<Search> newSearch = 309 mySearchCacheSvc.tryToMarkSearchAsInProgress(search, theRequestPartitionId); 310 if (newSearch.isPresent()) { 311 ourLog.trace("Launching new search"); 312 search = newSearch.get(); 313 String resourceType = search.getResourceType(); 314 SearchParameterMap params = search.getSearchParameterMap() 315 .orElseThrow(() -> new IllegalStateException("No map in PASSCOMPLET search")); 316 IFhirResourceDao<?> resourceDao = myDaoRegistry.getResourceDao(resourceType); 317 318 SearchTaskParameters parameters = new SearchTaskParameters( 319 search, 320 resourceDao, 321 params, 322 resourceType, 323 theRequestDetails, 324 theRequestPartitionId, 325 myOnRemoveSearchTask, 326 mySyncSize); 327 parameters.setLoadingThrottleForUnitTests(myLoadingThrottleForUnitTests); 328 SearchContinuationTask task = 329 (SearchContinuationTask) myBeanFactory.getBean(SearchConfig.CONTINUE_TASK, parameters); 330 myIdToSearchTask.put(search.getUuid(), task); 331 task.call(); 332 } 333 } 334 335 if (!search.getStatus().isDone()) { 336 AsyncUtil.sleep(500); 337 } 338 } 339 340 ourLog.trace("Finished looping"); 341 342 List<JpaPid> pids = fetchResultPids(theUuid, theFrom, theTo, theRequestDetails, search, theRequestPartitionId); 343 344 updateSearchExpiryOrNull(search, theRequestPartitionId); 345 346 ourLog.trace("Fetched {} results", pids.size()); 347 348 return pids; 349 } 350 351 @Nonnull 352 private List<JpaPid> fetchResultPids( 353 String theUuid, 354 int theFrom, 355 int theTo, 356 @Nullable RequestDetails theRequestDetails, 357 Search theSearch, 358 RequestPartitionId theRequestPartitionId) { 359 List<JpaPid> pids = mySearchResultCacheSvc.fetchResultPids( 360 theSearch, theFrom, theTo, theRequestDetails, theRequestPartitionId); 361 if (pids == null) { 362 throw myExceptionSvc.newUnknownSearchException(theUuid); 363 } 364 return pids; 365 } 366 367 private void updateSearchExpiryOrNull(Search theSearch, RequestPartitionId theRequestPartitionId) { 368 // The created time may be null in some unit tests 369 if (theSearch.getCreated() != null) { 370 // start tracking last-access-time for this search when it is more than halfway to expire by created time 371 // we do this to avoid generating excessive write traffic on busy cached searches. 372 long expireAfterMillis = myStorageSettings.getExpireSearchResultsAfterMillis(); 373 long createdCutoff = theSearch.getCreated().getTime() + expireAfterMillis; 374 if (createdCutoff - System.currentTimeMillis() < expireAfterMillis / 2) { 375 theSearch.setExpiryOrNull(DateUtils.addMinutes(new Date(), SEARCH_EXPIRY_OFFSET_MINUTES)); 376 // TODO: A nice future enhancement might be to make this flush be asynchronous 377 mySearchCacheSvc.save(theSearch, theRequestPartitionId); 378 } 379 } 380 } 381 382 @Override 383 public IBundleProvider registerSearch( 384 final IFhirResourceDao<?> theCallingDao, 385 final SearchParameterMap theParams, 386 String theResourceType, 387 CacheControlDirective theCacheControlDirective, 388 @Nullable RequestDetails theRequestDetails) { 389 final String searchUuid = UUID.randomUUID().toString(); 390 391 final String queryString = theParams.toNormalizedQueryString(); 392 ourLog.debug("Registering new search {}", searchUuid); 393 394 RequestPartitionId requestPartitionId = null; 395 if (theRequestDetails instanceof SystemRequestDetails srd) { 396 requestPartitionId = srd.getRequestPartitionId(); 397 } 398 399 // If an explicit request partition wasn't provided, calculate the request 400 // partition after invoking STORAGE_PRESEARCH_REGISTERED just in case any interceptors 401 // made changes which could affect the calculated partition 402 if (requestPartitionId == null) { 403 404 // Invoke any STORAGE_PRESEARCH_PARTITION_SELECTED interceptor hooks 405 NonPersistedSearch search = new NonPersistedSearch(theResourceType); 406 search.setUuid(searchUuid); 407 myStorageInterceptorHooks.callStoragePresearchPartitionSelected(theRequestDetails, theParams, search); 408 409 requestPartitionId = myRequestPartitionHelperSvc.determineReadPartitionForRequestForSearchType( 410 theRequestDetails, theResourceType, theParams); 411 } 412 413 Search search = new Search(); 414 QueryParameterUtils.populateSearchEntity( 415 theParams, theResourceType, searchUuid, queryString, search, requestPartitionId); 416 417 // Invoke any STORAGE_PRESEARCH_REGISTERED interceptor hooks 418 myStorageInterceptorHooks.callStoragePresearchRegistered( 419 theRequestDetails, theParams, search, requestPartitionId); 420 421 validateSearch(theParams); 422 423 Class<? extends IBaseResource> resourceTypeClass = 424 myContext.getResourceDefinition(theResourceType).getImplementingClass(); 425 final ISearchBuilder<JpaPid> sb = mySearchBuilderFactory.newSearchBuilder(theResourceType, resourceTypeClass); 426 sb.setFetchSize(mySyncSize); 427 sb.setRequireTotal(theParams.getCount() != null); 428 429 final Integer loadSynchronousUpTo = getLoadSynchronousUpToOrNull(theCacheControlDirective); 430 boolean isOffsetQuery = theParams.isOffsetQuery(); 431 432 // todo someday - not today. 433 // SearchStrategyFactory.ISearchStrategy searchStrategy = mySearchStrategyFactory.pickStrategy(theResourceType, 434 // theParams, theRequestDetails); 435 // return searchStrategy.get(); 436 437 if (theParams.isLoadSynchronous() || loadSynchronousUpTo != null || isOffsetQuery) { 438 if (mySearchStrategyFactory.isSupportsHSearchDirect(theResourceType, theParams, theRequestDetails)) { 439 ourLog.info("Search {} is using direct load strategy", searchUuid); 440 SearchStrategyFactory.ISearchStrategy direct = mySearchStrategyFactory.makeDirectStrategy( 441 searchUuid, theResourceType, theParams, theRequestDetails); 442 443 try { 444 return direct.get(); 445 } catch (ResourceNotFoundInIndexException theE) { 446 // some resources were not found in index, so we will inform this and resort to JPA search 447 ourLog.warn( 448 "Some resources were not found in index. Make sure all resources were indexed. Resorting to database search."); 449 } 450 } 451 452 // we need a max to fetch for synchronous searches; 453 // otherwise we'll explode memory. 454 Integer maxToLoad = getSynchronousMaxResultsToFetch(theParams, loadSynchronousUpTo); 455 ourLog.debug("Setting a max fetch value of {} for synchronous search", maxToLoad); 456 sb.setMaxResultsToFetch(maxToLoad); 457 458 ourLog.debug("Search {} is loading in synchronous mode", searchUuid); 459 return mySynchronousSearchSvc.executeQuery( 460 theParams, theRequestDetails, searchUuid, sb, loadSynchronousUpTo, requestPartitionId); 461 } 462 463 /* 464 * See if there are any cached searches whose results we can return 465 * instead 466 */ 467 SearchCacheStatusEnum cacheStatus = SearchCacheStatusEnum.MISS; 468 if (theCacheControlDirective != null && theCacheControlDirective.isNoCache()) { 469 cacheStatus = SearchCacheStatusEnum.NOT_TRIED; 470 } 471 472 if (cacheStatus != SearchCacheStatusEnum.NOT_TRIED) { 473 if (theParams.getEverythingMode() == null) { 474 if (myStorageSettings.getReuseCachedSearchResultsForMillis() != null) { 475 PersistedJpaBundleProvider foundSearchProvider = findCachedQuery( 476 theParams, theResourceType, theRequestDetails, queryString, requestPartitionId); 477 if (foundSearchProvider != null) { 478 foundSearchProvider.setCacheStatus(SearchCacheStatusEnum.HIT); 479 return foundSearchProvider; 480 } 481 } 482 } 483 } 484 485 PersistedJpaSearchFirstPageBundleProvider retVal = submitSearch( 486 theCallingDao, theParams, theResourceType, theRequestDetails, sb, requestPartitionId, search); 487 retVal.setCacheStatus(cacheStatus); 488 return retVal; 489 } 490 491 /** 492 * The max results to return if this is a synchronous search. 493 * <p> 494 * We'll look in this order: 495 * * load synchronous up to (on params) 496 * * param count (+ offset) 497 * * StorageSettings fetch size default max 498 * * 499 */ 500 private Integer getSynchronousMaxResultsToFetch(SearchParameterMap theParams, Integer theLoadSynchronousUpTo) { 501 if (theLoadSynchronousUpTo != null) { 502 return theLoadSynchronousUpTo; 503 } 504 505 if (theParams.getCount() != null) { 506 int valToReturn = theParams.getCount() + 1; 507 if (theParams.getOffset() != null) { 508 valToReturn += theParams.getOffset(); 509 } 510 return valToReturn; 511 } 512 513 if (myStorageSettings.getFetchSizeDefaultMaximum() != null) { 514 return myStorageSettings.getFetchSizeDefaultMaximum(); 515 } 516 517 return myStorageSettings.getInternalSynchronousSearchSize(); 518 } 519 520 private void validateSearch(SearchParameterMap theParams) { 521 /* 522 * Having duplicate identical params in the search (e.g. Patient?gender=male&gender=male) is not 523 * technically wrong, but it's inefficient and can slow query execution down. Checking for it also 524 * adds CPU load itself though, so we only check this in an assert to hopefully catch errors in tests. 525 */ 526 assert checkNoDuplicateParameters(theParams) 527 : "Duplicate parameters found in query: " + theParams.toNormalizedQueryString(); 528 529 validateIncludes(theParams.getIncludes(), Constants.PARAM_INCLUDE); 530 validateIncludes(theParams.getRevIncludes(), Constants.PARAM_REVINCLUDE); 531 } 532 533 /** 534 * This method detects whether we have any duplicate lists of parameters and returns 535 * {@literal true} if none are found. For example, the following query would result 536 * in this method returning {@literal false}: 537 * <code>Patient?name=bart,homer&name=bart,homer</code> 538 * <p> 539 * This is not an optimized test, and it's not technically even prohibited to have 540 * duplicates like these in queries so this method should only be called as a 541 * part of an {@literal assert} statement to catch errors in tests. 542 */ 543 private boolean checkNoDuplicateParameters(SearchParameterMap theParams) { 544 HashSet<List<IQueryParameterType>> lists = new HashSet<>(); 545 for (List<List<IQueryParameterType>> andList : theParams.values()) { 546 547 lists.clear(); 548 for (int i = 0; i < andList.size(); i++) { 549 List<IQueryParameterType> orListI = andList.get(i); 550 if (!orListI.isEmpty() && !lists.add(orListI)) { 551 return false; 552 } 553 } 554 } 555 return true; 556 } 557 558 private void validateIncludes(Set<Include> includes, String name) { 559 for (Include next : includes) { 560 String value = next.getValue(); 561 if (value.equals(Constants.INCLUDE_STAR) || isBlank(value)) { 562 continue; 563 } 564 565 String paramType = next.getParamType(); 566 String paramName = next.getParamName(); 567 String paramTargetType = next.getParamTargetType(); 568 569 if (isBlank(paramType) || isBlank(paramName)) { 570 String msg = myContext 571 .getLocalizer() 572 .getMessageSanitized(SearchCoordinatorSvcImpl.class, "invalidInclude", name, value, ""); 573 throw new InvalidRequestException(Msg.code(2018) + msg); 574 } 575 576 if (!myDaoRegistry.isResourceTypeSupported(paramType)) { 577 String resourceTypeMsg = myContext 578 .getLocalizer() 579 .getMessageSanitized(SearchCoordinatorSvcImpl.class, "invalidResourceType", paramType); 580 String msg = myContext 581 .getLocalizer() 582 .getMessage( 583 SearchCoordinatorSvcImpl.class, 584 "invalidInclude", 585 UrlUtil.sanitizeUrlPart(name), 586 UrlUtil.sanitizeUrlPart(value), 587 resourceTypeMsg); // last param is pre-sanitized 588 throw new InvalidRequestException(Msg.code(2017) + msg); 589 } 590 591 if (isNotBlank(paramTargetType) && !myDaoRegistry.isResourceTypeSupported(paramTargetType)) { 592 String resourceTypeMsg = myContext 593 .getLocalizer() 594 .getMessageSanitized(SearchCoordinatorSvcImpl.class, "invalidResourceType", paramTargetType); 595 String msg = myContext 596 .getLocalizer() 597 .getMessage( 598 SearchCoordinatorSvcImpl.class, 599 "invalidInclude", 600 UrlUtil.sanitizeUrlPart(name), 601 UrlUtil.sanitizeUrlPart(value), 602 resourceTypeMsg); // last param is pre-sanitized 603 throw new InvalidRequestException(Msg.code(2016) + msg); 604 } 605 606 if (!Constants.INCLUDE_STAR.equals(paramName) 607 && mySearchParamRegistry.getActiveSearchParam( 608 paramType, paramName, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH) 609 == null) { 610 List<String> validNames = mySearchParamRegistry 611 .getActiveSearchParams(paramType, ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH) 612 .values() 613 .stream() 614 .filter(t -> t.getParamType() == RestSearchParameterTypeEnum.REFERENCE) 615 .map(t -> UrlUtil.sanitizeUrlPart(t.getName())) 616 .sorted() 617 .collect(Collectors.toList()); 618 String searchParamMessage = myContext 619 .getLocalizer() 620 .getMessage( 621 BaseStorageDao.class, 622 "invalidSearchParameter", 623 UrlUtil.sanitizeUrlPart(paramName), 624 UrlUtil.sanitizeUrlPart(paramType), 625 validNames); 626 String msg = myContext 627 .getLocalizer() 628 .getMessage( 629 SearchCoordinatorSvcImpl.class, 630 "invalidInclude", 631 UrlUtil.sanitizeUrlPart(name), 632 UrlUtil.sanitizeUrlPart(value), 633 searchParamMessage); // last param is pre-sanitized 634 throw new InvalidRequestException(Msg.code(2015) + msg); 635 } 636 } 637 } 638 639 @Override 640 public Optional<Integer> getSearchTotal( 641 String theUuid, @Nullable RequestDetails theRequestDetails, RequestPartitionId theRequestPartitionId) { 642 SearchTask task = myIdToSearchTask.get(theUuid); 643 if (task != null) { 644 return Optional.ofNullable(task.awaitInitialSync()); 645 } 646 647 /* 648 * In case there is no running search, if the total is listed as accurate we know one is coming 649 * so let's wait a bit for it to show up 650 */ 651 Optional<Search> search = myTxService 652 .withRequest(theRequestDetails) 653 .execute(() -> mySearchCacheSvc.fetchByUuid(theUuid, theRequestPartitionId)); 654 if (search.isPresent()) { 655 Optional<SearchParameterMap> searchParameterMap = search.get().getSearchParameterMap(); 656 if (searchParameterMap.isPresent() 657 && searchParameterMap.get().getSearchTotalMode() == SearchTotalModeEnum.ACCURATE) { 658 for (int i = 0; i < 10; i++) { 659 if (search.isPresent()) { 660 QueryParameterUtils.verifySearchHasntFailedOrThrowInternalErrorException(search.get()); 661 if (search.get().getTotalCount() != null) { 662 return Optional.of(search.get().getTotalCount()); 663 } 664 } 665 search = mySearchCacheSvc.fetchByUuid(theUuid, theRequestPartitionId); 666 } 667 } 668 } 669 670 return Optional.empty(); 671 } 672 673 @Nonnull 674 private PersistedJpaSearchFirstPageBundleProvider submitSearch( 675 IDao theCallingDao, 676 SearchParameterMap theParams, 677 String theResourceType, 678 RequestDetails theRequestDetails, 679 ISearchBuilder<JpaPid> theSb, 680 RequestPartitionId theRequestPartitionId, 681 Search theSearch) { 682 StopWatch w = new StopWatch(); 683 684 SearchTaskParameters stp = new SearchTaskParameters( 685 theSearch, 686 theCallingDao, 687 theParams, 688 theResourceType, 689 theRequestDetails, 690 theRequestPartitionId, 691 myOnRemoveSearchTask, 692 mySyncSize); 693 stp.setLoadingThrottleForUnitTests(myLoadingThrottleForUnitTests); 694 SearchTask task = (SearchTask) myBeanFactory.getBean(SearchConfig.SEARCH_TASK, stp); 695 myIdToSearchTask.put(theSearch.getUuid(), task); 696 task.call(); 697 698 PersistedJpaSearchFirstPageBundleProvider retVal = myPersistedJpaBundleProviderFactory.newInstanceFirstPage( 699 theRequestDetails, task, theSb, theRequestPartitionId); 700 701 ourLog.debug("Search initial phase completed in {}ms", w.getMillis()); 702 return retVal; 703 } 704 705 @Nullable 706 private PersistedJpaBundleProvider findCachedQuery( 707 SearchParameterMap theParams, 708 String theResourceType, 709 RequestDetails theRequestDetails, 710 String theQueryString, 711 RequestPartitionId theRequestPartitionId) { 712 // May be null 713 return myTxService 714 .withRequest(theRequestDetails) 715 .withRequestPartitionId(theRequestPartitionId) 716 .execute(() -> { 717 IInterceptorBroadcaster compositeBroadcaster = 718 CompositeInterceptorBroadcaster.newCompositeBroadcaster( 719 myInterceptorBroadcaster, theRequestDetails); 720 721 // Interceptor call: STORAGE_PRECHECK_FOR_CACHED_SEARCH 722 723 HookParams params = new HookParams() 724 .add(SearchParameterMap.class, theParams) 725 .add(RequestDetails.class, theRequestDetails) 726 .addIfMatchesType(ServletRequestDetails.class, theRequestDetails); 727 boolean canUseCache = 728 compositeBroadcaster.callHooks(Pointcut.STORAGE_PRECHECK_FOR_CACHED_SEARCH, params); 729 if (!canUseCache) { 730 return null; 731 } 732 733 // Check for a search matching the given hash 734 Search searchToUse = findSearchToUseOrNull(theQueryString, theResourceType, theRequestPartitionId); 735 if (searchToUse == null) { 736 return null; 737 } 738 739 ourLog.debug("Reusing search {} from cache", searchToUse.getUuid()); 740 // Interceptor call: JPA_PERFTRACE_SEARCH_REUSING_CACHED 741 params = new HookParams() 742 .add(SearchParameterMap.class, theParams) 743 .add(RequestDetails.class, theRequestDetails) 744 .addIfMatchesType(ServletRequestDetails.class, theRequestDetails); 745 compositeBroadcaster.callHooks(Pointcut.JPA_PERFTRACE_SEARCH_REUSING_CACHED, params); 746 747 return myPersistedJpaBundleProviderFactory.newInstance(theRequestDetails, searchToUse.getUuid()); 748 }); 749 } 750 751 @Nullable 752 private Search findSearchToUseOrNull( 753 String theQueryString, String theResourceType, RequestPartitionId theRequestPartitionId) { 754 // createdCutoff is in recent past 755 final Instant createdCutoff = 756 Instant.now().minus(myStorageSettings.getReuseCachedSearchResultsForMillis(), ChronoUnit.MILLIS); 757 758 Optional<Search> candidate = mySearchCacheSvc.findCandidatesForReuse( 759 theResourceType, theQueryString, createdCutoff, theRequestPartitionId); 760 return candidate.orElse(null); 761 } 762 763 @Nullable 764 private Integer getLoadSynchronousUpToOrNull(CacheControlDirective theCacheControlDirective) { 765 final Integer loadSynchronousUpTo; 766 if (theCacheControlDirective != null && theCacheControlDirective.isNoStore()) { 767 if (theCacheControlDirective.getMaxResults() != null) { 768 loadSynchronousUpTo = theCacheControlDirective.getMaxResults(); 769 if (loadSynchronousUpTo > myStorageSettings.getCacheControlNoStoreMaxResultsUpperLimit()) { 770 throw new InvalidRequestException(Msg.code(1165) + Constants.HEADER_CACHE_CONTROL + " header " 771 + Constants.CACHE_CONTROL_MAX_RESULTS + " value must not exceed " 772 + myStorageSettings.getCacheControlNoStoreMaxResultsUpperLimit()); 773 } 774 } else { 775 loadSynchronousUpTo = 100; 776 } 777 } else { 778 loadSynchronousUpTo = null; 779 } 780 return loadSynchronousUpTo; 781 } 782 783 /** 784 * Creates a {@link Pageable} using a start and end index 785 */ 786 @SuppressWarnings("WeakerAccess") 787 @Nullable 788 public static Pageable toPage(final int theFromIndex, int theToIndex) { 789 int pageSize = theToIndex - theFromIndex; 790 if (pageSize < 1) { 791 return null; 792 } 793 794 int pageIndex = theFromIndex / pageSize; 795 796 return new PageRequest(pageIndex, pageSize, Sort.unsorted()) { 797 @Serial 798 private static final long serialVersionUID = 1L; 799 800 @Override 801 public long getOffset() { 802 return theFromIndex; 803 } 804 }; 805 } 806}