• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openmrs / openmrs-core / 37033370540

02 Oct 2026 04:21PM UTC coverage: 66.43% (+0.1%) from 66.293%
37033370540

push

github

ibacher
TRUNK-6810: Extend cluster-safety to RolePrivilegeCache (#6627)

281 of 297 new or added lines in 9 files covered. (94.61%)

2 existing lines in 1 file now uncovered.

25927 of 39029 relevant lines covered (66.43%)

0.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.23
/api/src/main/java/org/openmrs/api/cache/GlobalPropertyCache.java
1
/**
2
 * This Source Code Form is subject to the terms of the Mozilla Public License,
3
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
4
 * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
5
 * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
6
 *
7
 * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
8
 * graphic logo is a trademark of OpenMRS Inc.
9
 */
10
package org.openmrs.api.cache;
11

12
import java.io.Serializable;
13
import java.util.Locale;
14
import java.util.concurrent.CompletableFuture;
15
import java.util.concurrent.ConcurrentHashMap;
16
import java.util.concurrent.ConcurrentMap;
17
import java.util.concurrent.ExecutionException;
18
import java.util.concurrent.Future;
19
import java.util.concurrent.Semaphore;
20
import java.util.concurrent.atomic.AtomicBoolean;
21
import java.util.function.Function;
22

23
import org.infinispan.Cache;
24
import org.infinispan.spring.embedded.provider.SpringEmbeddedCacheManager;
25
import org.openmrs.GlobalProperty;
26
import org.openmrs.api.context.Daemon;
27
import org.openmrs.api.db.AdministrationDAO;
28
import org.openmrs.util.OpenmrsConstants;
29
import org.slf4j.Logger;
30
import org.slf4j.LoggerFactory;
31
import org.springframework.beans.factory.DisposableBean;
32
import org.springframework.beans.factory.annotation.Autowired;
33
import org.springframework.beans.factory.annotation.Qualifier;
34
import org.springframework.context.ApplicationListener;
35
import org.springframework.context.event.ContextRefreshedEvent;
36
import org.springframework.stereotype.Component;
37
import org.springframework.transaction.PlatformTransactionManager;
38
import org.springframework.transaction.TransactionManager;
39
import org.springframework.transaction.support.TransactionTemplate;
40

41
/**
42
 * Caches the global property lookups made through
43
 * {@link org.openmrs.api.AdministrationService#getGlobalProperty(String)}, including lookups of
44
 * properties that do not exist. Values are immutable {@link Entry} snapshots rather than
45
 * {@link GlobalProperty} entities, which are mutable and lazily load their privileges.
46
 * <p>
47
 * Entries are keyed by the lower-cased name, so a write evicts one key whatever spelling it was
48
 * read with, and each entry is only served for the exact name it was loaded for, since
49
 * {@link AdministrationDAO#getGlobalPropertyObject(String)} may treat other spellings differently.
50
 * A property stored under a name that does not lower-case to the key it was read with is not
51
 * cached, and an insert clears the whole cache, since it can change what a lookup of an equivalent
52
 * spelling returns.
53
 * <p>
54
 * The caller never fills the cache. On a miss, the property is read through the caller's own
55
 * session, which sees the caller's uncommitted changes, and a fill is started in the background.
56
 * The fill reads the property in a new transaction on a daemon thread, so it only sees committed
57
 * data and never touches the caller's transaction. Evictions go through {@link CacheInvalidation},
58
 * so a fill only keeps its entry if nothing was evicted, on any node, while it ran.
59
 * <p>
60
 * Within a transaction, evictions are applied once, when the transaction completes. Until then the
61
 * transaction reads the properties it has written, or every property after a {@link #clear()},
62
 * without the cache, so it always reads its own writes, while other transactions keep reading the
63
 * committed values. After a write through the API commits, the property is therefore never served
64
 * from before that write.
65
 * <p>
66
 * {@link org.openmrs.api.AdministrationService} evicts a property as soon as it is written, and
67
 * {@link org.openmrs.api.db.hibernate.GlobalPropertyCacheInterceptor} evicts any property Hibernate
68
 * flushes, which covers code that changes a loaded {@link GlobalProperty} directly. Such a change
69
 * is only visible to cache hits once it has been flushed. Changes to the {@code global_property}
70
 * table made outside Hibernate, for example with SQL, are not detected, but the
71
 * {@code global-properties} cache template's lifespan limits how long they can be served stale.
72
 *
73
 * @since 2.8.10
74
 */
75
@Component("globalPropertyCache")
76
public class GlobalPropertyCache implements ApplicationListener<ContextRefreshedEvent>, DisposableBean {
77

78
        private static final Logger log = LoggerFactory.getLogger(GlobalPropertyCache.class);
1 ✔
79

80
        public static final String CACHE_NAME = "globalProperties";
81

82
        static final int MAX_CONCURRENT_FILLS = 4;
83

84
        /** Capability token issued by {@link Daemon}, letting fills run on daemon threads. */
85
        private static volatile Daemon.CallerKey daemonCallerKey;
86

87
        private final SpringEmbeddedCacheManager cacheManager;
88

89
        private final AdministrationDAO dao;
90

91
        private final TransactionTemplate readOnlyTransaction;
92

93
        /**
94
         * Runs a fill on another thread without waiting for it, returning a future for the thread's task,
95
         * or null if there is none. The task may fail without running the fill, for example if a daemon
96
         * cannot open a session.
97
         */
98
        private final Function<Runnable, Future<?>> backgroundRunner;
99

100
        /** Fills in progress, by property name, so concurrent misses start a single fill. */
101
        private final ConcurrentMap<String, Fill> fills = new ConcurrentHashMap<>();
1 ✔
102

103
        /**
104
         * Limits how many fills run at once, since each holds a database connection that callers may be
105
         * waiting for. A miss that finds none free skips its fill; a later miss starts one. Each miss first
106
         * reclaims the permits of fills whose task ended without running them.
107
         */
108
        private final Semaphore fillPermits = new Semaphore(MAX_CONCURRENT_FILLS);
1 ✔
109

110
        private final CacheInvalidation invalidation;
111

112
        @Autowired
113
        public GlobalPropertyCache(@Qualifier("apiCacheManager") SpringEmbeddedCacheManager cacheManager, AdministrationDAO dao,
114
            @Qualifier("transactionManager") TransactionManager transactionManager) {
115
                this(cacheManager, dao, (PlatformTransactionManager) transactionManager,
1 ✔
116
                        task -> Daemon.runNewDaemonTask(task, daemonCallerKey()));
1 ✔
117
        }
1 ✔
118

119
        GlobalPropertyCache(SpringEmbeddedCacheManager cacheManager, AdministrationDAO dao,
120
            PlatformTransactionManager transactionManager, Function<Runnable, Future<?>> backgroundRunner) {
1 ✔
121
                this.cacheManager = cacheManager;
1 ✔
122
                this.dao = dao;
1 ✔
123
                this.backgroundRunner = backgroundRunner;
1 ✔
124

125
                this.readOnlyTransaction = new TransactionTemplate(transactionManager);
1 ✔
126
                this.readOnlyTransaction.setReadOnly(true);
1 ✔
127

128
                this.invalidation = new CacheInvalidation("the global property cache", this::getCache,
1 ✔
129
                        cacheManager.getNativeCacheManager());
1 ✔
130
        }
1 ✔
131

132
        @Override
133
        public void destroy() {
134
                invalidation.close();
1 ✔
135
        }
1 ✔
136

137
        /**
138
         * Receives the {@link Daemon} caller key. Called only by {@link Daemon} during its initialization.
139
         *
140
         * @param callerKey the caller key issued by {@link Daemon}
141
         */
142
        public static void setDaemonCallerKey(Daemon.CallerKey callerKey) {
143
                if (callerKey != null && daemonCallerKey == null) {
1 ✔
144
                        daemonCallerKey = callerKey;
1 ✔
145
                }
146
        }
1 ✔
147

148
        private static Daemon.CallerKey daemonCallerKey() {
149
                if (daemonCallerKey == null) {
1 ✔
150
                        // Guarantee Daemon has initialized and therefore handed us the key, regardless of the order in
151
                        // which the two classes were first loaded.
152
                        Daemon.ensureInitialized();
×
153
                }
154
                return daemonCallerKey;
1 ✔
155
        }
156

157
        /**
158
         * Returns a snapshot of the named property. On a miss it is read through the caller's session and a
159
         * fill of the cache is started in the background. Never returns null; a property that does not
160
         * exist is returned as {@link Entry#ABSENT}.
161
         * <p>
162
         * The snapshot does not record whether the current user may view the property, so callers must
163
         * check {@link Entry#getViewPrivilege()} on every call.
164
         *
165
         * @param propertyName the name of the property, not null
166
         * @return a snapshot of the property
167
         */
168
        public Entry get(String propertyName) {
169
                Cache<Object, Object> cache = getCache();
1 ✔
170
                if (cache == null || propertyName == null || invalidation.isWrittenInCurrentTransaction(key(propertyName))) {
1 ✔
171
                        return Entry.of(dao.getGlobalPropertyObject(propertyName));
1 ✔
172
                }
173

174
                Object cached = cache.get(key(propertyName));
1 ✔
175
                if (cached instanceof CachedEntry && ((CachedEntry) cached).isFor(propertyName)) {
1 ✔
176
                        return ((CachedEntry) cached).entry;
1 ✔
177
                }
178

179
                Entry loaded = Entry.of(dao.getGlobalPropertyObject(propertyName));
1 ✔
180
                // a fill cannot replace an entry cached for another spelling of the name, and loading may have
181
                // flushed a write of the property
182
                if (!(cached instanceof CachedEntry) && !invalidation.isWrittenInCurrentTransaction(key(propertyName))) {
1 ✔
183
                        startFill(propertyName);
1 ✔
184
                }
185
                return loaded;
1 ✔
186
        }
187

188
        /**
189
         * Returns the cached snapshot of the named property without loading it, or null if it is not cached
190
         * or the current transaction has written it. Only for tests that must observe the cache.
191
         *
192
         * @param propertyName the name of the property
193
         * @return a snapshot of the property, or null if it is not cached
194
         */
195
        Entry getIfCached(String propertyName) {
196
                Cache<Object, Object> cache = getCache();
1 ✔
197
                if (cache == null || propertyName == null || invalidation.isWrittenInCurrentTransaction(key(propertyName))) {
1 ✔
198
                        return null;
1 ✔
199
                }
200

201
                Object cached = cache.get(key(propertyName));
1 ✔
202
                return cached instanceof CachedEntry && ((CachedEntry) cached).isFor(propertyName) ? ((CachedEntry) cached).entry
1 ✔
203
                        : null;
204
        }
205

206
        /**
207
         * Evicts the named property, for example because it has been saved or purged.
208
         * <p>
209
         * Within a transaction the property is evicted when the transaction completes, whether it commits
210
         * or rolls back, and until then the transaction reads the property without the cache. Outside one
211
         * it is evicted immediately.
212
         *
213
         * @param propertyName the name of the property, not null
214
         */
215
        public void evict(String propertyName) {
216
                if (OpenmrsConstants.GP_CASE_SENSITIVE_DATABASE_STRING_COMPARISON.equalsIgnoreCase(propertyName)) {
1 ✔
217
                        // changes what the DAO returns for every spelling of every name
218
                        clear();
1 ✔
219
                } else {
220
                        invalidation.invalidate(key(propertyName));
1 ✔
221
                }
222
        }
1 ✔
223

224
        /**
225
         * Evicts every property. As with {@link #evict(String)}, within a transaction this happens when the
226
         * transaction completes, and until then the transaction reads every property without the cache.
227
         */
228
        public void clear() {
229
                invalidation.invalidate(CacheInvalidation.ALL);
1 ✔
230
        }
1 ✔
231

232
        /**
233
         * Clears the cache whenever the application context is refreshed, which happens whenever modules
234
         * are started or stopped.
235
         */
236
        @Override
237
        public void onApplicationEvent(ContextRefreshedEvent event) {
238
                clear();
1 ✔
239
        }
1 ✔
240

241
        /**
242
         * Waits for the fills in progress to finish. Fills only affect what later calls find in the cache,
243
         * so this is only needed where a caller must observe a fill, for example in tests.
244
         */
245
        void awaitFills() {
246
                fills.forEach((propertyName, fill) -> {
1 ✔
247
                        Future<?> task = fill.task.join();
1 ✔
248
                        if (task != null) {
1 ✔
249
                                try {
250
                                        task.get();
1 ✔
NEW
251
                                } catch (InterruptedException e) {
×
NEW
252
                                        Thread.currentThread().interrupt();
×
NEW
253
                                        return;
×
NEW
254
                                } catch (ExecutionException e) {
×
NEW
255
                                        finish(propertyName, fill);
×
256
                                }
1 ✔
257
                        }
258
                        fill.done.join();
1 ✔
259
                });
1 ✔
260
        }
1 ✔
261

262
        /**
263
         * Evicts every property immediately, even within a transaction, which still reads every property
264
         * without the cache until it completes. Only for tests that load data behind the API.
265
         */
266
        void clearNow() {
267
                invalidation.clearNow();
1 ✔
268
        }
1 ✔
269

270
        /**
271
         * Forgets which properties the current transaction has written, so that it reads through the cache
272
         * again and does not evict them when it completes. Only for tests that load data behind the API but
273
         * still need to observe cache hits.
274
         */
275
        void forgetWritesInCurrentTransaction() {
276
                invalidation.forgetWritesInCurrentTransaction();
1 ✔
277
        }
1 ✔
278

279
        /**
280
         * Starts a fill unless one is already in progress for the property or {@link #MAX_CONCURRENT_FILLS}
281
         * are running. The in-progress marker is published before the fill starts and only that marker is
282
         * removed when it finishes, so the fill may run on any thread, including this one.
283
         */
284
        private void startFill(String propertyName) {
285
                reclaimFillsThatNeverRan();
1 ✔
286
                if (!fillPermits.tryAcquire()) {
1 ✔
287
                        return;
1 ✔
288
                }
289

290
                Fill marker = new Fill();
1 ✔
291
                if (fills.putIfAbsent(propertyName, marker) != null) {
1 ✔
292
                        fillPermits.release();
1 ✔
293
                        return;
1 ✔
294
                }
295

296
                Future<?> task = null;
1 ✔
297
                try {
298
                        task = backgroundRunner.apply(() -> {
1 ✔
299
                                try {
300
                                        fill(propertyName);
1 ✔
301
                                } catch (RuntimeException e) {
×
302
                                        log.warn("Could not fill the global property cache with {}", propertyName, e);
×
303
                                } finally {
304
                                        finish(propertyName, marker);
1 ✔
305
                                }
306
                        });
1 ✔
307
                } catch (RuntimeException e) {
1 ✔
308
                        log.warn("Could not start a fill of the global property cache with {}", propertyName, e);
1 ✔
309
                        finish(propertyName, marker);
1 ✔
310
                } finally {
311
                        marker.task.complete(task);
1 ✔
312
                }
313
        }
1 ✔
314

315
        /**
316
         * Finishes the fills whose task has ended, which only leaves a fill unfinished if the task ended
317
         * without running it. There are at most {@link #MAX_CONCURRENT_FILLS}.
318
         */
319
        private void reclaimFillsThatNeverRan() {
320
                fills.forEach((propertyName, fill) -> {
1 ✔
321
                        Future<?> task = fill.task.getNow(null);
1 ✔
322
                        if (task != null && task.isDone()) {
1 ✔
323
                                finish(propertyName, fill);
1 ✔
324
                        }
325
                });
1 ✔
326
        }
1 ✔
327

328
        /** Releases a fill's marker and permit. Only the first call for a fill has any effect. */
329
        private void finish(String propertyName, Fill fill) {
330
                if (fill.finished.compareAndSet(false, true)) {
1 ✔
331
                        fills.remove(propertyName, fill);
1 ✔
332
                        fillPermits.release();
1 ✔
333
                        fill.done.complete(null);
1 ✔
334
                }
335
        }
1 ✔
336

337
        /**
338
         * Runs on a background thread: reads the property in a new read-only transaction and caches it
339
         * unless an eviction happens in the meantime, or it is stored under a name that does not lower-case
340
         * to the same key, whose writes would evict a different key.
341
         */
342
        private void fill(String propertyName) {
343
                Cache<Object, Object> cache = getCache();
1 ✔
344
                if (cache == null) {
1 ✔
345
                        return;
×
346
                }
347

348
                Object loadedAt = CacheInvalidation.currentGeneration(cache);
1 ✔
349
                readOnlyTransaction.executeWithoutResult(status -> {
1 ✔
350
                        GlobalProperty property = dao.getGlobalPropertyObject(propertyName);
1 ✔
351
                        if (property != null) {
1 ✔
352
                                String storedName = dao.getStoredGlobalPropertyName(propertyName);
1 ✔
353
                                if (storedName == null || !key(storedName).equals(key(propertyName))) {
1 ✔
354
                                        return;
1 ✔
355
                                }
356
                        }
357
                        CacheInvalidation.putIfCurrent(cache, key(propertyName), new CachedEntry(propertyName, Entry.of(property)),
1 ✔
358
                            loadedAt);
359
                });
1 ✔
360
        }
1 ✔
361

362
        private static String key(String propertyName) {
363
                return propertyName.toLowerCase(Locale.ROOT);
1 ✔
364
        }
365

366
        @SuppressWarnings("unchecked")
367
        private Cache<Object, Object> getCache() {
368
                org.springframework.cache.Cache cache = cacheManager.getCache(CACHE_NAME);
1 ✔
369
                return cache == null ? null : (Cache<Object, Object>) cache.getNativeCache();
1 ✔
370
        }
371

372
        /** A fill in progress. */
373
        private static final class Fill {
1 ✔
374

375
                private final AtomicBoolean finished = new AtomicBoolean();
1 ✔
376

377
                /** Completed once the fill has finished. */
378
                private final CompletableFuture<Void> done = new CompletableFuture<>();
1 ✔
379

380
                /** The thread's task running the fill, completed as soon as it has been started. */
381
                private final CompletableFuture<Future<?>> task = new CompletableFuture<>();
1 ✔
382
        }
383

384
        /** A cached {@link Entry} and the exact name it was loaded for. */
385
        static final class CachedEntry implements Serializable {
386

387
                private static final long serialVersionUID = 1L;
388

389
                private final String propertyName;
390

391
                private final Entry entry;
392

393
                CachedEntry(String propertyName, Entry entry) {
1 ✔
394
                        this.propertyName = propertyName;
1 ✔
395
                        this.entry = entry;
1 ✔
396
                }
1 ✔
397

398
                boolean isFor(String propertyName) {
399
                        return this.propertyName.equals(propertyName);
1 ✔
400
                }
401

402
                Entry getEntry() {
403
                        return entry;
1 ✔
404
                }
405
        }
406

407
        /**
408
         * An immutable snapshot of a global property's value and view privilege, or {@link #ABSENT} if the
409
         * property does not exist.
410
         */
411
        public static final class Entry implements Serializable {
412

413
                private static final long serialVersionUID = 1L;
414

415
                public static final Entry ABSENT = new Entry(false, null, null);
1 ✔
416

417
                private final boolean present;
418

419
                private final String value;
420

421
                private final String viewPrivilege;
422

423
                private Entry(boolean present, String value, String viewPrivilege) {
1 ✔
424
                        this.present = present;
1 ✔
425
                        this.value = value;
1 ✔
426
                        this.viewPrivilege = viewPrivilege;
1 ✔
427
                }
1 ✔
428

429
                /**
430
                 * @param globalProperty the property to snapshot, or null if it does not exist
431
                 * @return a snapshot of <code>globalProperty</code>, or {@link #ABSENT} if it is null
432
                 */
433
                public static Entry of(GlobalProperty globalProperty) {
434
                        if (globalProperty == null) {
1 ✔
435
                                return ABSENT;
1 ✔
436
                        }
437

438
                        String viewPrivilege = globalProperty.getViewPrivilege() == null ? null
1 ✔
439
                                : globalProperty.getViewPrivilege().getPrivilege();
1 ✔
440
                        return new Entry(true, globalProperty.getPropertyValue(), viewPrivilege);
1 ✔
441
                }
442

443
                /**
444
                 * @return true if the property exists
445
                 */
446
                public boolean isPresent() {
447
                        return present;
1 ✔
448
                }
449

450
                /**
451
                 * @return the property's value, or null if it has none or does not exist
452
                 */
453
                public String getValue() {
454
                        return value;
1 ✔
455
                }
456

457
                /**
458
                 * @return the name of the privilege needed to view the property, or null if none is needed
459
                 */
460
                public String getViewPrivilege() {
461
                        return viewPrivilege;
1 ✔
462
                }
463
        }
464
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc