001package ca.uhn.fhir.jpa.search.cache;
002
003/*-
004 * #%L
005 * HAPI FHIR JPA 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.interceptor.model.RequestPartitionId;
024import ca.uhn.fhir.jpa.api.config.DaoConfig;
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.entity.Search;
029import ca.uhn.fhir.jpa.entity.SearchInclude;
030import ca.uhn.fhir.jpa.model.search.SearchStatusEnum;
031import com.google.common.annotations.VisibleForTesting;
032import com.google.common.collect.Lists;
033import org.apache.commons.lang3.Validate;
034import org.apache.commons.lang3.time.DateUtils;
035import org.hl7.fhir.dstu3.model.InstantType;
036import org.slf4j.Logger;
037import org.slf4j.LoggerFactory;
038import org.springframework.beans.factory.annotation.Autowired;
039import org.springframework.data.domain.PageRequest;
040import org.springframework.data.domain.Slice;
041import org.springframework.transaction.PlatformTransactionManager;
042import org.springframework.transaction.TransactionDefinition;
043import org.springframework.transaction.support.TransactionTemplate;
044
045import javax.transaction.Transactional;
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 = 10 * DateUtils.MILLIS_PER_SECOND;
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        @Autowired
074        private ISearchDao mySearchDao;
075        @Autowired
076        private ISearchResultDao mySearchResultDao;
077        @Autowired
078        private ISearchIncludeDao mySearchIncludeDao;
079        @Autowired
080        private PlatformTransactionManager myTxManager;
081        @Autowired
082        private DaoConfig myDaoConfig;
083
084        @VisibleForTesting
085        public void setCutoffSlackForUnitTest(long theCutoffSlack) {
086                myCutoffSlack = theCutoffSlack;
087        }
088
089        @Transactional(Transactional.TxType.REQUIRED)
090        @Override
091        public Search save(Search theSearch) {
092                Search newSearch;
093                if (theSearch.getId() == null) {
094                        newSearch = mySearchDao.save(theSearch);
095                        for (SearchInclude next : theSearch.getIncludes()) {
096                                mySearchIncludeDao.save(next);
097                        }
098                } else {
099                        newSearch = mySearchDao.save(theSearch);
100                }
101                return newSearch;
102        }
103
104        @Override
105        @Transactional(Transactional.TxType.REQUIRED)
106        public Optional<Search> fetchByUuid(String theUuid) {
107                Validate.notBlank(theUuid);
108                return mySearchDao.findByUuidAndFetchIncludes(theUuid);
109        }
110
111        void setSearchDaoForUnitTest(ISearchDao theSearchDao) {
112                mySearchDao = theSearchDao;
113        }
114
115        void setTxManagerForUnitTest(PlatformTransactionManager theTxManager) {
116                myTxManager = theTxManager;
117        }
118
119        @Override
120        @Transactional(Transactional.TxType.NEVER)
121        public Optional<Search> tryToMarkSearchAsInProgress(Search theSearch) {
122                ourLog.trace("Going to try to change search status from {} to {}", theSearch.getStatus(), SearchStatusEnum.LOADING);
123                try {
124                        TransactionTemplate txTemplate = new TransactionTemplate(myTxManager);
125                        txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
126                        txTemplate.afterPropertiesSet();
127                        return txTemplate.execute(t -> {
128                                Search search = mySearchDao.findById(theSearch.getId()).orElse(theSearch);
129
130                                if (search.getStatus() != SearchStatusEnum.PASSCMPLET) {
131                                        throw new IllegalStateException("Can't change to LOADING because state is " + theSearch.getStatus());
132                                }
133                                search.setStatus(SearchStatusEnum.LOADING);
134                                Search newSearch = mySearchDao.save(search);
135                                return Optional.of(newSearch);
136                        });
137                } catch (Exception e) {
138                        ourLog.warn("Failed to activate search: {}", e.toString());
139                        ourLog.trace("Failed to activate search", e);
140                        return Optional.empty();
141                }
142        }
143
144        @Override
145        public Optional<Search> findCandidatesForReuse(String theResourceType, String theQueryString, Instant theCreatedAfter, RequestPartitionId theRequestPartitionId) {
146                String queryString = Search.createSearchQueryStringForStorage(theQueryString, theRequestPartitionId);
147
148                int hashCode = queryString.hashCode();
149                Collection<Search> candidates = mySearchDao.findWithCutoffOrExpiry(theResourceType, hashCode, Date.from(theCreatedAfter));
150
151                for (Search nextCandidateSearch : candidates) {
152                        // We should only reuse our search if it was created within the permitted window
153                        // Date.after() is unreliable.  Instant.isAfter() always works.
154                        if (queryString.equals(nextCandidateSearch.getSearchQueryString()) && nextCandidateSearch.getCreated().toInstant().isAfter(theCreatedAfter)) {
155                                return Optional.of(nextCandidateSearch);
156                        }
157                }
158
159                return Optional.empty();
160        }
161
162        @Transactional(Transactional.TxType.NEVER)
163        @Override
164        public void pollForStaleSearchesAndDeleteThem() {
165                if (!myDaoConfig.isExpireSearchResults()) {
166                        return;
167                }
168
169                long cutoffMillis = myDaoConfig.getExpireSearchResultsAfterMillis();
170                if (myDaoConfig.getReuseCachedSearchResultsForMillis() != null) {
171                        cutoffMillis = cutoffMillis + myDaoConfig.getReuseCachedSearchResultsForMillis();
172                }
173                final Date cutoff = new Date((now() - cutoffMillis) - myCutoffSlack);
174
175                if (ourNowForUnitTests != null) {
176                        ourLog.info("Searching for searches which are before {} - now is {}", new InstantType(cutoff), new InstantType(new Date(now())));
177                }
178
179                ourLog.debug("Searching for searches which are before {}", cutoff);
180
181                TransactionTemplate tt = new TransactionTemplate(myTxManager);
182
183                // Mark searches as deleted if they should be
184                final Slice<Long> toMarkDeleted = tt.execute(theStatus ->
185                        mySearchDao.findWhereCreatedBefore(cutoff, new Date(), PageRequest.of(0, ourMaximumSearchesToCheckForDeletionCandidacy))
186                );
187                assert toMarkDeleted != null;
188                for (final Long nextSearchToDelete : toMarkDeleted) {
189                        ourLog.debug("Deleting search with PID {}", nextSearchToDelete);
190                        tt.execute(t -> {
191                                mySearchDao.updateDeleted(nextSearchToDelete, true);
192                                return null;
193                        });
194                }
195
196                // Delete searches that are marked as deleted
197                final Slice<Long> toDelete = tt.execute(theStatus ->
198                        mySearchDao.findDeleted(PageRequest.of(0, ourMaximumSearchesToCheckForDeletionCandidacy))
199                );
200                assert toDelete != null;
201                for (final Long nextSearchToDelete : toDelete) {
202                        ourLog.debug("Deleting search with PID {}", nextSearchToDelete);
203                        tt.execute(t -> {
204                                deleteSearch(nextSearchToDelete);
205                                return null;
206                        });
207                }
208
209                int count = toDelete.getContent().size();
210                if (count > 0) {
211                        if (ourLog.isDebugEnabled() || "true".equalsIgnoreCase(System.getProperty("test"))) {
212                                Long total = tt.execute(t -> mySearchDao.count());
213                                ourLog.debug("Deleted {} searches, {} remaining", count, total);
214                        }
215                }
216        }
217
218        private void deleteSearch(final Long theSearchPid) {
219                mySearchDao.findById(theSearchPid).ifPresent(searchToDelete -> {
220                        mySearchIncludeDao.deleteForSearch(searchToDelete.getId());
221
222                        /*
223                         * Note, we're only deleting up to 500 results in an individual search here. This
224                         * is to prevent really long running transactions in cases where there are
225                         * huge searches with tons of results in them. By the time we've gotten here
226                         * we have marked the parent Search entity as deleted, so it's not such a
227                         * huge deal to be only partially deleting search results. They'll get deleted
228                         * eventually
229                         */
230                        int max = ourMaximumResultsToDeleteInOnePass;
231                        Slice<Long> resultPids = mySearchResultDao.findForSearch(PageRequest.of(0, max), searchToDelete.getId());
232                        if (resultPids.hasContent()) {
233                                List<List<Long>> partitions = Lists.partition(resultPids.getContent(), ourMaximumResultsToDeleteInOneStatement);
234                                for (List<Long> nextPartition : partitions) {
235                                        mySearchResultDao.deleteByIds(nextPartition);
236                                }
237
238                        }
239
240                        // Only delete if we don't have results left in this search
241                        if (resultPids.getNumberOfElements() < max) {
242                                ourLog.debug("Deleting search {}/{} - Created[{}]", searchToDelete.getId(), searchToDelete.getUuid(), new InstantType(searchToDelete.getCreated()));
243                                mySearchDao.deleteByPid(searchToDelete.getId());
244                        } else {
245                                ourLog.debug("Purged {} search results for deleted search {}/{}", resultPids.getSize(), searchToDelete.getId(), searchToDelete.getUuid());
246                        }
247                });
248        }
249
250        @VisibleForTesting
251        public static void setMaximumSearchesToCheckForDeletionCandidacyForUnitTest(int theMaximumSearchesToCheckForDeletionCandidacy) {
252                ourMaximumSearchesToCheckForDeletionCandidacy = theMaximumSearchesToCheckForDeletionCandidacy;
253        }
254
255        @VisibleForTesting
256        public static void setMaximumResultsToDeleteInOnePassForUnitTest(int theMaximumResultsToDeleteInOnePass) {
257                ourMaximumResultsToDeleteInOnePass = theMaximumResultsToDeleteInOnePass;
258        }
259
260        @VisibleForTesting
261        public static void setMaximumResultsToDeleteForUnitTest(int theMaximumResultsToDelete) {
262                ourMaximumResultsToDeleteInOneStatement = theMaximumResultsToDelete;
263        }
264
265        /**
266         * This is for unit tests only, do not call otherwise
267         */
268        @VisibleForTesting
269        public static void setNowForUnitTests(Long theNowForUnitTests) {
270                ourNowForUnitTests = theNowForUnitTests;
271        }
272
273        private static long now() {
274                if (ourNowForUnitTests != null) {
275                        return ourNowForUnitTests;
276                }
277                return System.currentTimeMillis();
278        }
279
280}