001/*-
002 * #%L
003 * HAPI FHIR Storage api
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%
019 */
020package ca.uhn.fhir.jpa.util;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.interceptor.model.RequestPartitionId;
024import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
025import ca.uhn.fhir.jpa.model.dao.JpaPid;
026import ca.uhn.fhir.jpa.model.entity.TagTypeEnum;
027import ca.uhn.fhir.sl.cache.Cache;
028import ca.uhn.fhir.sl.cache.CacheFactory;
029import jakarta.annotation.Nonnull;
030import jakarta.annotation.Nullable;
031import org.apache.commons.lang3.builder.EqualsBuilder;
032import org.apache.commons.lang3.builder.HashCodeBuilder;
033import org.apache.commons.lang3.builder.ToStringBuilder;
034import org.apache.commons.lang3.builder.ToStringStyle;
035import org.hl7.fhir.instance.model.api.IIdType;
036import org.springframework.transaction.support.TransactionSynchronization;
037import org.springframework.transaction.support.TransactionSynchronizationManager;
038
039import java.util.Collection;
040import java.util.EnumMap;
041import java.util.List;
042import java.util.Map;
043import java.util.Objects;
044import java.util.function.Function;
045
046import static java.util.concurrent.TimeUnit.MINUTES;
047import static java.util.concurrent.TimeUnit.SECONDS;
048import static org.apache.commons.lang3.StringUtils.isNotBlank;
049
050/**
051 * This class acts as a central spot for all of the many Caffeine caches we use in HAPI FHIR.
052 * <p>
053 * The API is super simplistic, and caches are all 1-minute, max 10000 entries for starters. We could definitely add nuance to this,
054 * which will be much easier now that this is being centralized. Some logging/monitoring would be good too.
055 */
056// TODO: JA2 extract an interface for this class and use it everywhere
057public class MemoryCacheService {
058
059        private final JpaStorageSettings myStorageSettings;
060        private final EnumMap<CacheEnum, Cache<?, ?>> myCaches = new EnumMap<>(CacheEnum.class);
061
062        public MemoryCacheService(JpaStorageSettings theStorageSettings) {
063                myStorageSettings = theStorageSettings;
064
065                populateCaches();
066        }
067
068        private void populateCaches() {
069                for (CacheEnum next : CacheEnum.values()) {
070
071                        long timeoutSeconds;
072                        int maximumSize;
073
074                        Cache<Object, Object> nextCache;
075                        switch (next) {
076                                case RES_TYPE_TO_RES_TYPE_ID:
077                                        nextCache = CacheFactory.buildEternal(250, 500);
078                                        break;
079                                case HASH_IDENTITY_TO_SEARCH_PARAM_IDENTITY:
080                                        nextCache = CacheFactory.buildEternal(5_000, 50_000);
081                                        break;
082                                case RESOURCE_IDENTIFIER_SYSTEM_TO_PID:
083                                        nextCache = CacheFactory.buildEternal(250, 1_000);
084                                        break;
085                                case PATIENT_IDENTIFIER_TO_FHIR_ID:
086                                case NAME_TO_PARTITION:
087                                case ID_TO_PARTITION:
088                                case PID_TO_FORCED_ID:
089                                case MATCH_URL:
090                                case RESOURCE_LOOKUP_BY_FORCED_ID:
091                                case HISTORY_COUNT:
092                                case TAG_DEFINITION:
093                                case RESOURCE_CONDITIONAL_CREATE_VERSION:
094                                case FHIRPATH_EXPRESSION:
095                                default:
096                                        timeoutSeconds = SECONDS.convert(1, MINUTES);
097                                        maximumSize = 10000;
098                                        if (myStorageSettings.isMassIngestionMode()) {
099                                                timeoutSeconds = SECONDS.convert(50, MINUTES);
100                                                maximumSize = 100000;
101                                        }
102                                        nextCache = CacheFactory.build(SECONDS.toMillis(timeoutSeconds), maximumSize);
103                                        break;
104                        }
105
106                        myCaches.put(next, nextCache);
107                }
108        }
109
110        public <K, T> T get(CacheEnum theCache, K theKey, Function<K, T> theSupplier) {
111                assert theCache.getKeyType().isAssignableFrom(theKey.getClass());
112                return doGet(theCache, theKey, theSupplier);
113        }
114
115        protected <K, T> T doGet(CacheEnum theCache, K theKey, Function<K, T> theSupplier) {
116                Cache<K, T> cache = getCache(theCache);
117                return cache.get(theKey, theSupplier);
118        }
119
120        /**
121         * Fetch an item from the cache if it exists, and use the loading function to
122         * obtain it otherwise.
123         * <p>
124         * This method will put the value into the cache using {@link #putAfterCommit(CacheEnum, Object, Object)}.
125         */
126        public <K, T> T getThenPutAfterCommit(CacheEnum theCache, K theKey, Function<K, T> theSupplier) {
127                assert theCache.getKeyType().isAssignableFrom(theKey.getClass());
128                T retVal = getIfPresent(theCache, theKey);
129                if (retVal == null) {
130                        retVal = theSupplier.apply(theKey);
131                        putAfterCommit(theCache, theKey, retVal);
132                }
133                return retVal;
134        }
135
136        public <K, V> V getIfPresent(CacheEnum theCache, K theKey) {
137                assert theCache.getKeyType().isAssignableFrom(theKey.getClass());
138                return doGetIfPresent(theCache, theKey);
139        }
140
141        protected <K, V> V doGetIfPresent(CacheEnum theCache, K theKey) {
142                return (V) getCache(theCache).getIfPresent(theKey);
143        }
144
145        public <K, V> void put(CacheEnum theCache, K theKey, V theValue) {
146                assert theCache.getKeyType().isAssignableFrom(theKey.getClass())
147                                : "Key type " + theKey.getClass() + " doesn't match expected " + theCache.getKeyType() + " for cache "
148                                                + theCache;
149                doPut(theCache, theKey, theValue);
150        }
151
152        protected <K, V> void doPut(CacheEnum theCache, K theKey, V theValue) {
153                getCache(theCache).put(theKey, theValue);
154        }
155
156        /**
157         * This method registers a transaction synchronization that puts an entry in the cache
158         * if and when the current database transaction successfully commits. If the
159         * transaction is rolled back, the key+value passed into this method will
160         * not be added to the cache.
161         * <p>
162         * This is useful for situations where you want to store something that has been
163         * resolved in the DB during the current transaction, but it's not yet guaranteed
164         * that this item will successfully save to the DB. Use this method in that case
165         * in order to avoid cache poisoning.
166         */
167        public <K, V> void putAfterCommit(CacheEnum theCache, K theKey, V theValue) {
168                assert theCache.getKeyType().isAssignableFrom(theKey.getClass())
169                                : "Key type " + theKey.getClass() + " doesn't match expected " + theCache.getKeyType() + " for cache "
170                                                + theCache;
171                if (TransactionSynchronizationManager.isSynchronizationActive()) {
172                        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
173                                @Override
174                                public void afterCommit() {
175                                        put(theCache, theKey, theValue);
176                                }
177                        });
178                } else {
179                        put(theCache, theKey, theValue);
180                }
181        }
182
183        @SuppressWarnings("unchecked")
184        public <K, V> Map<K, V> getAllPresent(CacheEnum theCache, Collection<K> theKeys) {
185                return doGetAllPresent(theCache, theKeys);
186        }
187
188        @SuppressWarnings("unchecked")
189        protected <K, V> Map<K, V> doGetAllPresent(CacheEnum theCache, Collection<K> theKeys) {
190                return (Map<K, V>) getCache(theCache).getAllPresent(theKeys);
191        }
192
193        public void invalidateAllCaches() {
194                myCaches.values().forEach(Cache::invalidateAll);
195        }
196
197        private <K, T> Cache<K, T> getCache(CacheEnum theCache) {
198                return (Cache<K, T>) myCaches.get(theCache);
199        }
200
201        public long getEstimatedSize(CacheEnum theCache) {
202                return getCache(theCache).estimatedSize();
203        }
204
205        public void invalidateCaches(CacheEnum... theCaches) {
206                for (CacheEnum next : theCaches) {
207                        getCache(next).invalidateAll();
208                }
209        }
210
211        public enum CacheEnum {
212                TAG_DEFINITION(TagDefinitionCacheKey.class),
213                /**
214                 * Key type: {@link ForcedIdCacheKey}
215                 * Value type: {@literal JpaResourceLookup}
216                 */
217                RESOURCE_LOOKUP_BY_FORCED_ID(ForcedIdCacheKey.class),
218                FHIRPATH_EXPRESSION(String.class),
219                /**
220                 * Key type: {@literal Long}
221                 * Value type: {@literal Optional<String>}
222                 */
223                PID_TO_FORCED_ID(JpaPid.class),
224                MATCH_URL(String.class),
225                RESOURCE_CONDITIONAL_CREATE_VERSION(JpaPid.class),
226                HISTORY_COUNT(HistoryCountKey.class),
227                NAME_TO_PARTITION(String.class),
228                ID_TO_PARTITION(Integer.class),
229                HASH_IDENTITY_TO_SEARCH_PARAM_IDENTITY(Long.class),
230                RES_TYPE_TO_RES_TYPE_ID(String.class),
231                RESOURCE_IDENTIFIER_SYSTEM_TO_PID(String.class),
232                PATIENT_IDENTIFIER_TO_FHIR_ID(IdentifierKey.class);
233
234                private final Class<?> myKeyType;
235
236                CacheEnum(Class<?> theKeyType) {
237                        myKeyType = theKeyType;
238                }
239
240                public Class<?> getKeyType() {
241                        return myKeyType;
242                }
243        }
244
245        public record IdentifierKey(String system, String value) {}
246
247        public static class TagDefinitionCacheKey {
248
249                private final TagTypeEnum myType;
250                private final String mySystem;
251                private final String myCode;
252                private final String myVersion;
253                private final int myHashCode;
254                private Boolean myUserSelected;
255
256                public TagDefinitionCacheKey(
257                                TagTypeEnum theType, String theSystem, String theCode, String theVersion, Boolean theUserSelected) {
258                        myType = theType;
259                        mySystem = theSystem;
260                        myCode = theCode;
261                        myVersion = theVersion;
262                        myUserSelected = theUserSelected;
263                        myHashCode = new HashCodeBuilder(17, 37)
264                                        .append(myType)
265                                        .append(mySystem)
266                                        .append(myCode)
267                                        .append(myVersion)
268                                        .append(myUserSelected)
269                                        .toHashCode();
270                }
271
272                @Override
273                public boolean equals(Object theO) {
274                        boolean retVal = false;
275                        if (theO instanceof TagDefinitionCacheKey) {
276                                TagDefinitionCacheKey that = (TagDefinitionCacheKey) theO;
277
278                                retVal = new EqualsBuilder()
279                                                .append(myType, that.myType)
280                                                .append(mySystem, that.mySystem)
281                                                .append(myCode, that.myCode)
282                                                .isEquals();
283                        }
284                        return retVal;
285                }
286
287                @Override
288                public int hashCode() {
289                        return myHashCode;
290                }
291        }
292
293        public static class HistoryCountKey {
294                private final String myTypeName;
295                private final Long myInstanceId;
296                private final Integer myPartitionId;
297                private final int myHashCode;
298
299                private HistoryCountKey(@Nullable String theTypeName, @Nullable JpaPid theInstanceId) {
300                        myTypeName = theTypeName;
301                        if (theInstanceId != null) {
302                                myInstanceId = theInstanceId.getId();
303                                myPartitionId = theInstanceId.getPartitionId();
304                        } else {
305                                myInstanceId = null;
306                                myPartitionId = null;
307                        }
308                        myHashCode = new HashCodeBuilder()
309                                        .append(myTypeName)
310                                        .append(myInstanceId)
311                                        .append(myPartitionId)
312                                        .toHashCode();
313                }
314
315                @Override
316                public boolean equals(Object theO) {
317                        boolean retVal = false;
318                        if (theO instanceof HistoryCountKey) {
319                                HistoryCountKey that = (HistoryCountKey) theO;
320                                retVal = new EqualsBuilder()
321                                                .append(myTypeName, that.myTypeName)
322                                                .append(myInstanceId, that.myInstanceId)
323                                                .isEquals();
324                        }
325                        return retVal;
326                }
327
328                @Override
329                public int hashCode() {
330                        return myHashCode;
331                }
332
333                public static HistoryCountKey forSystem() {
334                        return new HistoryCountKey(null, null);
335                }
336
337                public static HistoryCountKey forType(@Nonnull String theType) {
338                        assert isNotBlank(theType);
339                        return new HistoryCountKey(theType, null);
340                }
341
342                public static HistoryCountKey forInstance(@Nonnull JpaPid theInstanceId) {
343                        return new HistoryCountKey(null, theInstanceId);
344                }
345        }
346
347        public static class ForcedIdCacheKey {
348
349                private final String myResourceType;
350                private final String myResourceId;
351                private final List<Integer> myRequestPartitionIds;
352                private final int myHashCode;
353
354                public ForcedIdCacheKey(
355                                @Nullable String theResourceType,
356                                @Nonnull String theResourceId,
357                                @Nonnull RequestPartitionId theRequestPartitionId) {
358                        myResourceType = theResourceType;
359                        myResourceId = theResourceId;
360                        if (theRequestPartitionId.hasPartitionIds()) {
361                                myRequestPartitionIds = theRequestPartitionId.getPartitionIds();
362                        } else {
363                                myRequestPartitionIds = null;
364                        }
365                        myHashCode = Objects.hash(myResourceType, myResourceId, myRequestPartitionIds);
366                }
367
368                @Override
369                public String toString() {
370                        return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
371                                        .append("resType", myResourceType)
372                                        .append("resId", myResourceId)
373                                        .append("partId", myRequestPartitionIds)
374                                        .toString();
375                }
376
377                @Override
378                public boolean equals(Object theO) {
379                        if (this == theO) {
380                                return true;
381                        }
382                        if (!(theO instanceof ForcedIdCacheKey)) {
383                                return false;
384                        }
385                        ForcedIdCacheKey that = (ForcedIdCacheKey) theO;
386                        return Objects.equals(myResourceType, that.myResourceType)
387                                        && Objects.equals(myResourceId, that.myResourceId)
388                                        && Objects.equals(myRequestPartitionIds, that.myRequestPartitionIds);
389                }
390
391                @Override
392                public int hashCode() {
393                        return myHashCode;
394                }
395
396                /**
397                 * Creates and returns a new unqualified versionless IIdType instance
398                 */
399                public IIdType toIdType(FhirContext theFhirCtx) {
400                        return theFhirCtx.getVersion().newIdType(myResourceType, myResourceId);
401                }
402        }
403}