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