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}