001package ca.uhn.fhir.jpa.subscription.triggering; 002 003/*- 004 * #%L 005 * HAPI FHIR Subscription Server 006 * %% 007 * Copyright (C) 2014 - 2022 Smile CDR, Inc. 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022 023import ca.uhn.fhir.context.FhirContext; 024import ca.uhn.fhir.context.RuntimeResourceDefinition; 025import ca.uhn.fhir.i18n.Msg; 026import ca.uhn.fhir.interceptor.model.RequestPartitionId; 027import ca.uhn.fhir.jpa.api.config.DaoConfig; 028import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 029import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 030import ca.uhn.fhir.jpa.api.svc.ISearchCoordinatorSvc; 031import ca.uhn.fhir.jpa.model.sched.HapiJob; 032import ca.uhn.fhir.jpa.model.sched.ISchedulerService; 033import ca.uhn.fhir.jpa.model.sched.ScheduledJobDefinition; 034import ca.uhn.fhir.jpa.partition.SystemRequestDetails; 035import ca.uhn.fhir.jpa.searchparam.MatchUrlService; 036import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 037import ca.uhn.fhir.jpa.subscription.match.matcher.matching.IResourceModifiedConsumer; 038import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; 039import ca.uhn.fhir.model.dstu2.valueset.ResourceTypeEnum; 040import ca.uhn.fhir.rest.annotation.IdParam; 041import ca.uhn.fhir.rest.api.CacheControlDirective; 042import ca.uhn.fhir.rest.api.server.IBundleProvider; 043import ca.uhn.fhir.rest.api.server.storage.ResourcePersistentId; 044import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 045import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 046import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException; 047import ca.uhn.fhir.util.ParametersUtil; 048import ca.uhn.fhir.util.StopWatch; 049import ca.uhn.fhir.util.UrlUtil; 050import ca.uhn.fhir.util.ValidateUtil; 051import org.apache.commons.lang3.ObjectUtils; 052import org.apache.commons.lang3.Validate; 053import org.apache.commons.lang3.concurrent.BasicThreadFactory; 054import org.apache.commons.lang3.time.DateUtils; 055import org.apache.commons.lang3.tuple.Pair; 056import org.hl7.fhir.dstu2.model.IdType; 057import org.hl7.fhir.instance.model.api.IBaseParameters; 058import org.hl7.fhir.instance.model.api.IBaseResource; 059import org.hl7.fhir.instance.model.api.IIdType; 060import org.hl7.fhir.instance.model.api.IPrimitiveType; 061import org.quartz.JobExecutionContext; 062import org.slf4j.Logger; 063import org.slf4j.LoggerFactory; 064import org.springframework.beans.factory.annotation.Autowired; 065 066import javax.annotation.PostConstruct; 067import java.util.ArrayList; 068import java.util.Collections; 069import java.util.List; 070import java.util.UUID; 071import java.util.concurrent.ExecutorService; 072import java.util.concurrent.Future; 073import java.util.concurrent.LinkedBlockingQueue; 074import java.util.concurrent.RejectedExecutionException; 075import java.util.concurrent.RejectedExecutionHandler; 076import java.util.concurrent.ThreadPoolExecutor; 077import java.util.concurrent.TimeUnit; 078import java.util.stream.Collectors; 079 080import static ca.uhn.fhir.rest.server.provider.ProviderConstants.SUBSCRIPTION_TRIGGERING_PARAM_RESOURCE_ID; 081import static org.apache.commons.lang3.StringUtils.isBlank; 082import static org.apache.commons.lang3.StringUtils.isNotBlank; 083 084public class SubscriptionTriggeringSvcImpl implements ISubscriptionTriggeringSvc { 085 private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionTriggeringSvcImpl.class); 086 private static final int DEFAULT_MAX_SUBMIT = 10000; 087 private final List<SubscriptionTriggeringJobDetails> myActiveJobs = new ArrayList<>(); 088 @Autowired 089 private FhirContext myFhirContext; 090 @Autowired 091 private DaoRegistry myDaoRegistry; 092 @Autowired 093 private DaoConfig myDaoConfig; 094 @Autowired 095 private ISearchCoordinatorSvc mySearchCoordinatorSvc; 096 @Autowired 097 private MatchUrlService myMatchUrlService; 098 @Autowired 099 private IResourceModifiedConsumer myResourceModifiedConsumer; 100 private int myMaxSubmitPerPass = DEFAULT_MAX_SUBMIT; 101 private ExecutorService myExecutorService; 102 @Autowired 103 private ISchedulerService mySchedulerService; 104 105 @Override 106 public IBaseParameters triggerSubscription(List<IPrimitiveType<String>> theResourceIds, List<IPrimitiveType<String>> theSearchUrls, @IdParam IIdType theSubscriptionId) { 107 108 if (myDaoConfig.getSupportedSubscriptionTypes().isEmpty()) { 109 throw new PreconditionFailedException(Msg.code(22) + "Subscription processing not active on this server"); 110 } 111 112 // Throw a 404 if the subscription doesn't exist 113 if (theSubscriptionId != null) { 114 IFhirResourceDao<?> subscriptionDao = myDaoRegistry.getSubscriptionDao(); 115 IIdType subscriptionId = theSubscriptionId; 116 if (!subscriptionId.hasResourceType()) { 117 subscriptionId = subscriptionId.withResourceType(ResourceTypeEnum.SUBSCRIPTION.getCode()); 118 } 119 subscriptionDao.read(subscriptionId, SystemRequestDetails.forAllPartition()); 120 } 121 122 List<IPrimitiveType<String>> resourceIds = ObjectUtils.defaultIfNull(theResourceIds, Collections.emptyList()); 123 List<IPrimitiveType<String>> searchUrls = ObjectUtils.defaultIfNull(theSearchUrls, Collections.emptyList()); 124 125 // Make sure we have at least one resource ID or search URL 126 if (resourceIds.size() == 0 && searchUrls.size() == 0) { 127 throw new InvalidRequestException(Msg.code(23) + "No resource IDs or search URLs specified for triggering"); 128 } 129 130 // Resource URLs must be compete 131 for (IPrimitiveType<String> next : resourceIds) { 132 IdType resourceId = new IdType(next.getValue()); 133 ValidateUtil.isTrueOrThrowInvalidRequest(resourceId.hasResourceType(), SUBSCRIPTION_TRIGGERING_PARAM_RESOURCE_ID + " parameter must have resource type"); 134 ValidateUtil.isTrueOrThrowInvalidRequest(resourceId.hasIdPart(), SUBSCRIPTION_TRIGGERING_PARAM_RESOURCE_ID + " parameter must have resource ID part"); 135 } 136 137 // Search URLs must be valid 138 for (IPrimitiveType<String> next : searchUrls) { 139 if (!next.getValue().contains("?")) { 140 throw new InvalidRequestException(Msg.code(24) + "Search URL is not valid (must be in the form \"[resource type]?[optional params]\")"); 141 } 142 } 143 144 SubscriptionTriggeringJobDetails jobDetails = new SubscriptionTriggeringJobDetails(); 145 jobDetails.setJobId(UUID.randomUUID().toString()); 146 jobDetails.setRemainingResourceIds(resourceIds.stream().map(t -> t.getValue()).collect(Collectors.toList())); 147 jobDetails.setRemainingSearchUrls(searchUrls.stream().map(t -> t.getValue()).collect(Collectors.toList())); 148 if (theSubscriptionId != null) { 149 jobDetails.setSubscriptionId(theSubscriptionId.getIdPart()); 150 } 151 152 // Submit job for processing 153 synchronized (myActiveJobs) { 154 myActiveJobs.add(jobDetails); 155 ourLog.info("Subscription triggering requested for {} resource and {} search - Gave job ID: {} and have {} jobs", resourceIds.size(), searchUrls.size(), jobDetails.getJobId(), myActiveJobs.size()); 156 } 157 158 // Create a parameters response 159 IBaseParameters retVal = ParametersUtil.newInstance(myFhirContext); 160 IPrimitiveType<?> value = (IPrimitiveType<?>) myFhirContext.getElementDefinition("string").newInstance(); 161 value.setValueAsString("Subscription triggering job submitted as JOB ID: " + jobDetails.myJobId); 162 ParametersUtil.addParameterToParameters(myFhirContext, retVal, "information", value); 163 return retVal; 164 } 165 166 @Override 167 public void runDeliveryPass() { 168 169 synchronized (myActiveJobs) { 170 171 if (myActiveJobs.isEmpty()) { 172 return; 173 } 174 175 String activeJobIds = myActiveJobs.stream().map(SubscriptionTriggeringJobDetails::getJobId).collect(Collectors.joining(", ")); 176 ourLog.info("Starting pass: currently have {} active job IDs: {}", myActiveJobs.size(), activeJobIds); 177 178 SubscriptionTriggeringJobDetails activeJob = myActiveJobs.get(0); 179 180 runJob(activeJob); 181 182 // If the job is complete, remove it from the queue 183 if (activeJob.getRemainingResourceIds().isEmpty()) { 184 if (activeJob.getRemainingSearchUrls().isEmpty()) { 185 if (isBlank(activeJob.myCurrentSearchUuid)) { 186 myActiveJobs.remove(0); 187 String remainingJobsMsg = ""; 188 if (myActiveJobs.size() > 0) { 189 remainingJobsMsg = "(" + myActiveJobs.size() + " jobs remaining)"; 190 } 191 ourLog.info("Subscription triggering job {} is complete{}", activeJob.getJobId(), remainingJobsMsg); 192 } 193 } 194 } 195 196 } 197 198 } 199 200 private void runJob(SubscriptionTriggeringJobDetails theJobDetails) { 201 StopWatch sw = new StopWatch(); 202 ourLog.info("Starting pass of subscription triggering job {}", theJobDetails.getJobId()); 203 204 // Submit individual resources 205 int totalSubmitted = 0; 206 List<Pair<String, Future<Void>>> futures = new ArrayList<>(); 207 while (theJobDetails.getRemainingResourceIds().size() > 0 && totalSubmitted < myMaxSubmitPerPass) { 208 totalSubmitted++; 209 String nextResourceId = theJobDetails.getRemainingResourceIds().remove(0); 210 Future<Void> future = submitResource(theJobDetails.getSubscriptionId(), nextResourceId); 211 futures.add(Pair.of(nextResourceId, future)); 212 } 213 214 // Make sure these all succeeded in submitting 215 if (validateFuturesAndReturnTrueIfWeShouldAbort(futures)) { 216 return; 217 } 218 219 // If we don't have an active search started, and one needs to be.. start it 220 if (isBlank(theJobDetails.getCurrentSearchUuid()) && theJobDetails.getRemainingSearchUrls().size() > 0 && totalSubmitted < myMaxSubmitPerPass) { 221 String nextSearchUrl = theJobDetails.getRemainingSearchUrls().remove(0); 222 RuntimeResourceDefinition resourceDef = UrlUtil.parseUrlResourceType(myFhirContext, nextSearchUrl); 223 String queryPart = nextSearchUrl.substring(nextSearchUrl.indexOf('?')); 224 String resourceType = resourceDef.getName(); 225 226 IFhirResourceDao<?> callingDao = myDaoRegistry.getResourceDao(resourceType); 227 SearchParameterMap params = myMatchUrlService.translateMatchUrl(queryPart, resourceDef); 228 229 ourLog.info("Triggering job[{}] is starting a search for {}", theJobDetails.getJobId(), nextSearchUrl); 230 231 IBundleProvider search = mySearchCoordinatorSvc.registerSearch(callingDao, params, resourceType, new CacheControlDirective(), null, RequestPartitionId.allPartitions()); 232 theJobDetails.setCurrentSearchUuid(search.getUuid()); 233 theJobDetails.setCurrentSearchResourceType(resourceType); 234 theJobDetails.setCurrentSearchCount(params.getCount()); 235 theJobDetails.setCurrentSearchLastUploadedIndex(-1); 236 } 237 238 // If we have an active search going, submit resources from it 239 if (isNotBlank(theJobDetails.getCurrentSearchUuid()) && totalSubmitted < myMaxSubmitPerPass) { 240 int fromIndex = theJobDetails.getCurrentSearchLastUploadedIndex() + 1; 241 242 IFhirResourceDao<?> resourceDao = myDaoRegistry.getResourceDao(theJobDetails.getCurrentSearchResourceType()); 243 244 int maxQuerySize = myMaxSubmitPerPass - totalSubmitted; 245 int toIndex = fromIndex + maxQuerySize; 246 if (theJobDetails.getCurrentSearchCount() != null) { 247 toIndex = Math.min(toIndex, theJobDetails.getCurrentSearchCount()); 248 } 249 ourLog.info("Triggering job[{}] search {} requesting resources {} - {}", theJobDetails.getJobId(), theJobDetails.getCurrentSearchUuid(), fromIndex, toIndex); 250 List<ResourcePersistentId> resourceIds = mySearchCoordinatorSvc.getResources(theJobDetails.getCurrentSearchUuid(), fromIndex, toIndex, null); 251 252 ourLog.info("Triggering job[{}] delivering {} resources", theJobDetails.getJobId(), resourceIds.size()); 253 int highestIndexSubmitted = theJobDetails.getCurrentSearchLastUploadedIndex(); 254 255 for (ResourcePersistentId next : resourceIds) { 256 IBaseResource nextResource = resourceDao.readByPid(next); 257 Future<Void> future = submitResource(theJobDetails.getSubscriptionId(), nextResource); 258 futures.add(Pair.of(nextResource.getIdElement().getIdPart(), future)); 259 totalSubmitted++; 260 highestIndexSubmitted++; 261 } 262 263 if (validateFuturesAndReturnTrueIfWeShouldAbort(futures)) { 264 return; 265 } 266 267 theJobDetails.setCurrentSearchLastUploadedIndex(highestIndexSubmitted); 268 269 if (resourceIds.size() == 0 || (theJobDetails.getCurrentSearchCount() != null && toIndex >= theJobDetails.getCurrentSearchCount())) { 270 ourLog.info("Triggering job[{}] search {} has completed ", theJobDetails.getJobId(), theJobDetails.getCurrentSearchUuid()); 271 theJobDetails.setCurrentSearchResourceType(null); 272 theJobDetails.setCurrentSearchUuid(null); 273 theJobDetails.setCurrentSearchLastUploadedIndex(-1); 274 theJobDetails.setCurrentSearchCount(null); 275 } 276 } 277 278 ourLog.info("Subscription trigger job[{}] triggered {} resources in {}ms ({} res / second)", theJobDetails.getJobId(), totalSubmitted, sw.getMillis(), sw.getThroughput(totalSubmitted, TimeUnit.SECONDS)); 279 } 280 281 private boolean validateFuturesAndReturnTrueIfWeShouldAbort(List<Pair<String, Future<Void>>> theIdToFutures) { 282 283 for (Pair<String, Future<Void>> next : theIdToFutures) { 284 String nextDeliveredId = next.getKey(); 285 try { 286 Future<Void> nextFuture = next.getValue(); 287 nextFuture.get(); 288 ourLog.info("Finished redelivering {}", nextDeliveredId); 289 } catch (Exception e) { 290 ourLog.error("Failure triggering resource " + nextDeliveredId, e); 291 return true; 292 } 293 } 294 295 // Clear the list since it will potentially get reused 296 theIdToFutures.clear(); 297 return false; 298 } 299 300 private Future<Void> submitResource(String theSubscriptionId, String theResourceIdToTrigger) { 301 org.hl7.fhir.r4.model.IdType resourceId = new org.hl7.fhir.r4.model.IdType(theResourceIdToTrigger); 302 IFhirResourceDao dao = myDaoRegistry.getResourceDao(resourceId.getResourceType()); 303 IBaseResource resourceToTrigger = dao.read(resourceId, SystemRequestDetails.forAllPartition()); 304 305 return submitResource(theSubscriptionId, resourceToTrigger); 306 } 307 308 private Future<Void> submitResource(String theSubscriptionId, IBaseResource theResourceToTrigger) { 309 310 ourLog.info("Submitting resource {} to subscription {}", theResourceToTrigger.getIdElement().toUnqualifiedVersionless().getValue(), theSubscriptionId); 311 312 ResourceModifiedMessage msg = new ResourceModifiedMessage(myFhirContext, theResourceToTrigger, ResourceModifiedMessage.OperationTypeEnum.UPDATE); 313 msg.setSubscriptionId(theSubscriptionId); 314 315 return myExecutorService.submit(() -> { 316 for (int i = 0; ; i++) { 317 try { 318 myResourceModifiedConsumer.submitResourceModified(msg); 319 break; 320 } catch (Exception e) { 321 if (i >= 3) { 322 throw new InternalErrorException(Msg.code(25) + e); 323 } 324 325 ourLog.warn("Exception while retriggering subscriptions (going to sleep and retry): {}", e.toString()); 326 Thread.sleep(1000); 327 } 328 } 329 330 return null; 331 }); 332 333 } 334 335 public void cancelAll() { 336 synchronized (myActiveJobs) { 337 myActiveJobs.clear(); 338 } 339 } 340 341 /** 342 * Sets the maximum number of resources that will be submitted in a single pass 343 */ 344 public void setMaxSubmitPerPass(Integer theMaxSubmitPerPass) { 345 Integer maxSubmitPerPass = theMaxSubmitPerPass; 346 if (maxSubmitPerPass == null) { 347 maxSubmitPerPass = DEFAULT_MAX_SUBMIT; 348 } 349 Validate.isTrue(maxSubmitPerPass > 0, "theMaxSubmitPerPass must be > 0"); 350 myMaxSubmitPerPass = maxSubmitPerPass; 351 } 352 353 @PostConstruct 354 public void start() { 355 createExecutorService(); 356 scheduleJob(); 357 } 358 359 private void createExecutorService() { 360 LinkedBlockingQueue<Runnable> executorQueue = new LinkedBlockingQueue<>(1000); 361 BasicThreadFactory threadFactory = new BasicThreadFactory.Builder() 362 .namingPattern("SubscriptionTriggering-%d") 363 .daemon(false) 364 .priority(Thread.NORM_PRIORITY) 365 .build(); 366 RejectedExecutionHandler rejectedExecutionHandler = new RejectedExecutionHandler() { 367 @Override 368 public void rejectedExecution(Runnable theRunnable, ThreadPoolExecutor theExecutor) { 369 ourLog.info("Note: Subscription triggering queue is full ({} elements), waiting for a slot to become available!", executorQueue.size()); 370 StopWatch sw = new StopWatch(); 371 try { 372 executorQueue.put(theRunnable); 373 } catch (InterruptedException theE) { 374 // Restore interrupted state... 375 Thread.currentThread().interrupt(); 376 throw new RejectedExecutionException(Msg.code(26) + "Task " + theRunnable.toString() + 377 " rejected from " + theE.toString()); 378 } 379 ourLog.info("Slot become available after {}ms", sw.getMillis()); 380 } 381 }; 382 myExecutorService = new ThreadPoolExecutor( 383 0, 384 10, 385 0L, 386 TimeUnit.MILLISECONDS, 387 executorQueue, 388 threadFactory, 389 rejectedExecutionHandler); 390 } 391 392 private void scheduleJob() { 393 ScheduledJobDefinition jobDetail = new ScheduledJobDefinition(); 394 jobDetail.setId(getClass().getName()); 395 jobDetail.setJobClass(Job.class); 396 // Currently jobs ae kept in a local ArrayList so this should be a local job, and 397 // it can fire frequently without adding load 398 mySchedulerService.scheduleLocalJob(5 * DateUtils.MILLIS_PER_SECOND, jobDetail); 399 } 400 401 public int getActiveJobCount() { 402 return myActiveJobs.size(); 403 } 404 405 public static class Job implements HapiJob { 406 @Autowired 407 private ISubscriptionTriggeringSvc myTarget; 408 409 @Override 410 public void execute(JobExecutionContext theContext) { 411 myTarget.runDeliveryPass(); 412 } 413 } 414 415 private static class SubscriptionTriggeringJobDetails { 416 417 private String myJobId; 418 private String mySubscriptionId; 419 private List<String> myRemainingResourceIds; 420 private List<String> myRemainingSearchUrls; 421 private String myCurrentSearchUuid; 422 private Integer myCurrentSearchCount; 423 private String myCurrentSearchResourceType; 424 private int myCurrentSearchLastUploadedIndex; 425 426 Integer getCurrentSearchCount() { 427 return myCurrentSearchCount; 428 } 429 430 void setCurrentSearchCount(Integer theCurrentSearchCount) { 431 myCurrentSearchCount = theCurrentSearchCount; 432 } 433 434 String getCurrentSearchResourceType() { 435 return myCurrentSearchResourceType; 436 } 437 438 void setCurrentSearchResourceType(String theCurrentSearchResourceType) { 439 myCurrentSearchResourceType = theCurrentSearchResourceType; 440 } 441 442 String getJobId() { 443 return myJobId; 444 } 445 446 void setJobId(String theJobId) { 447 myJobId = theJobId; 448 } 449 450 String getSubscriptionId() { 451 return mySubscriptionId; 452 } 453 454 void setSubscriptionId(String theSubscriptionId) { 455 mySubscriptionId = theSubscriptionId; 456 } 457 458 List<String> getRemainingResourceIds() { 459 return myRemainingResourceIds; 460 } 461 462 void setRemainingResourceIds(List<String> theRemainingResourceIds) { 463 myRemainingResourceIds = theRemainingResourceIds; 464 } 465 466 List<String> getRemainingSearchUrls() { 467 return myRemainingSearchUrls; 468 } 469 470 void setRemainingSearchUrls(List<String> theRemainingSearchUrls) { 471 myRemainingSearchUrls = theRemainingSearchUrls; 472 } 473 474 String getCurrentSearchUuid() { 475 return myCurrentSearchUuid; 476 } 477 478 void setCurrentSearchUuid(String theCurrentSearchUuid) { 479 myCurrentSearchUuid = theCurrentSearchUuid; 480 } 481 482 int getCurrentSearchLastUploadedIndex() { 483 return myCurrentSearchLastUploadedIndex; 484 } 485 486 void setCurrentSearchLastUploadedIndex(int theCurrentSearchLastUploadedIndex) { 487 myCurrentSearchLastUploadedIndex = theCurrentSearchLastUploadedIndex; 488 } 489 } 490 491}