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

openmrs / openmrs-core / 36872660172

01 Oct 2026 01:58PM UTC coverage: 66.293% (+0.2%) from 66.046%
36872660172

push

github

ibacher
TRUNK-6707: Implement full caching for Global Properties (#6598)

219 of 229 new or added lines in 7 files covered. (95.63%)

1 existing line in 1 file now uncovered.

25757 of 38853 relevant lines covered (66.29%)

0.66 hits per line

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

96.53
/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.Collections;
14
import java.util.HashSet;
15
import java.util.Locale;
16
import java.util.Set;
17
import java.util.UUID;
18
import java.util.concurrent.CompletableFuture;
19
import java.util.concurrent.ConcurrentHashMap;
20
import java.util.concurrent.ConcurrentMap;
21
import java.util.concurrent.Semaphore;
22
import java.util.function.Consumer;
23

24
import org.infinispan.Cache;
25
import org.infinispan.context.Flag;
26
import org.infinispan.notifications.Listener;
27
import org.infinispan.notifications.cachemanagerlistener.annotation.Merged;
28
import org.infinispan.notifications.cachemanagerlistener.event.MergeEvent;
29
import org.infinispan.spring.embedded.provider.SpringEmbeddedCacheManager;
30
import org.openmrs.GlobalProperty;
31
import org.openmrs.api.context.Daemon;
32
import org.openmrs.api.db.AdministrationDAO;
33
import org.openmrs.util.OpenmrsConstants;
34
import org.slf4j.Logger;
35
import org.slf4j.LoggerFactory;
36
import org.springframework.beans.factory.DisposableBean;
37
import org.springframework.beans.factory.annotation.Autowired;
38
import org.springframework.beans.factory.annotation.Qualifier;
39
import org.springframework.context.ApplicationListener;
40
import org.springframework.context.event.ContextRefreshedEvent;
41
import org.springframework.stereotype.Component;
42
import org.springframework.transaction.PlatformTransactionManager;
43
import org.springframework.transaction.TransactionManager;
44
import org.springframework.transaction.support.TransactionSynchronization;
45
import org.springframework.transaction.support.TransactionSynchronizationManager;
46
import org.springframework.transaction.support.TransactionTemplate;
47

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

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

91
        public static final String CACHE_NAME = "globalProperties";
92

93
        /**
94
         * Key of the generation token, which every eviction replaces. The NUL character keeps it from
95
         * colliding with a lower-cased property name.
96
         */
97
        static final String GENERATION = "\0generation";
98

99
        /**
100
         * Recorded in the current transaction's written set by {@link #clear()}, meaning every property.
101
         */
102
        private static final String ALL_PROPERTIES = "\0all";
103

104
        static final int MAX_CONCURRENT_FILLS = 4;
105

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

109
        private final SpringEmbeddedCacheManager cacheManager;
110

111
        private final AdministrationDAO dao;
112

113
        private final TransactionTemplate readOnlyTransaction;
114

115
        /** Runs a fill on another thread without waiting for it. */
116
        private final Consumer<Runnable> backgroundRunner;
117

118
        /** Fills in progress, by property name, so concurrent misses start a single fill. */
119
        private final ConcurrentMap<String, CompletableFuture<Void>> fills = new ConcurrentHashMap<>();
1 ✔
120

121
        /**
122
         * Limits how many fills run at once, since each holds a database connection that callers may be
123
         * waiting for. A miss that finds none free skips its fill; a later miss starts one.
124
         */
125
        private final Semaphore fillPermits = new Semaphore(MAX_CONCURRENT_FILLS);
1 ✔
126

127
        private final PartitionMergeListener mergeListener = new PartitionMergeListener(this);
1 ✔
128

129
        @Autowired
130
        public GlobalPropertyCache(@Qualifier("apiCacheManager") SpringEmbeddedCacheManager cacheManager, AdministrationDAO dao,
131
            @Qualifier("transactionManager") TransactionManager transactionManager) {
132
                this(cacheManager, dao, (PlatformTransactionManager) transactionManager,
1 ✔
133
                        task -> Daemon.runNewDaemonTask(task, daemonCallerKey()));
1 ✔
134
        }
1 ✔
135

136
        GlobalPropertyCache(SpringEmbeddedCacheManager cacheManager, AdministrationDAO dao,
137
            PlatformTransactionManager transactionManager, Consumer<Runnable> backgroundRunner) {
1 ✔
138
                this.cacheManager = cacheManager;
1 ✔
139
                this.dao = dao;
1 ✔
140
                this.backgroundRunner = backgroundRunner;
1 ✔
141

142
                this.readOnlyTransaction = new TransactionTemplate(transactionManager);
1 ✔
143
                this.readOnlyTransaction.setReadOnly(true);
1 ✔
144

145
                cacheManager.getNativeCacheManager().addListener(mergeListener);
1 ✔
146
        }
1 ✔
147

148
        @Override
149
        public void destroy() {
150
                cacheManager.getNativeCacheManager().removeListener(mergeListener);
1 ✔
151
        }
1 ✔
152

153
        /**
154
         * Receives the {@link Daemon} caller key. Called only by {@link Daemon} during its initialization.
155
         *
156
         * @param callerKey the caller key issued by {@link Daemon}
157
         */
158
        public static void setDaemonCallerKey(Daemon.CallerKey callerKey) {
159
                if (callerKey != null && daemonCallerKey == null) {
1 ✔
160
                        daemonCallerKey = callerKey;
1 ✔
161
                }
162
        }
1 ✔
163

164
        private static Daemon.CallerKey daemonCallerKey() {
165
                if (daemonCallerKey == null) {
1 ✔
166
                        // Guarantee Daemon has initialized and therefore handed us the key, regardless of the order in
167
                        // which the two classes were first loaded.
NEW
168
                        Daemon.ensureInitialized();
×
169
                }
170
                return daemonCallerKey;
1 ✔
171
        }
172

173
        /**
174
         * Returns a snapshot of the named property. On a miss it is read through the caller's session and a
175
         * fill of the cache is started in the background. Never returns null; a property that does not
176
         * exist is returned as {@link Entry#ABSENT}.
177
         * <p>
178
         * The snapshot does not record whether the current user may view the property, so callers must
179
         * check {@link Entry#getViewPrivilege()} on every call.
180
         *
181
         * @param propertyName the name of the property, not null
182
         * @return a snapshot of the property
183
         */
184
        public Entry get(String propertyName) {
185
                Cache<Object, Object> cache = getCache();
1 ✔
186
                if (cache == null || propertyName == null || isWrittenInCurrentTransaction(propertyName)) {
1 ✔
187
                        return Entry.of(dao.getGlobalPropertyObject(propertyName));
1 ✔
188
                }
189

190
                Object cached = cache.get(key(propertyName));
1 ✔
191
                if (cached instanceof CachedEntry && ((CachedEntry) cached).isFor(propertyName)) {
1 ✔
192
                        return ((CachedEntry) cached).entry;
1 ✔
193
                }
194

195
                Entry loaded = Entry.of(dao.getGlobalPropertyObject(propertyName));
1 ✔
196
                // a fill cannot replace an entry cached for another spelling of the name, and loading may have
197
                // flushed a write of the property
198
                if (!(cached instanceof CachedEntry) && !isWrittenInCurrentTransaction(propertyName)) {
1 ✔
199
                        startFill(propertyName);
1 ✔
200
                }
201
                return loaded;
1 ✔
202
        }
203

204
        /**
205
         * Returns the cached snapshot of the named property without loading it, or null if it is not cached
206
         * or the current transaction has written it. Only for tests that must observe the cache.
207
         *
208
         * @param propertyName the name of the property
209
         * @return a snapshot of the property, or null if it is not cached
210
         */
211
        Entry getIfCached(String propertyName) {
212
                Cache<Object, Object> cache = getCache();
1 ✔
213
                if (cache == null || propertyName == null || isWrittenInCurrentTransaction(propertyName)) {
1 ✔
214
                        return null;
1 ✔
215
                }
216

217
                Object cached = cache.get(key(propertyName));
1 ✔
218
                return cached instanceof CachedEntry && ((CachedEntry) cached).isFor(propertyName) ? ((CachedEntry) cached).entry
1 ✔
219
                        : null;
220
        }
221

222
        /**
223
         * Evicts the named property, for example because it has been saved or purged.
224
         * <p>
225
         * Within a transaction the property is evicted when the transaction completes, whether it commits
226
         * or rolls back, and until then the transaction reads the property without the cache. Outside one
227
         * it is evicted immediately.
228
         *
229
         * @param propertyName the name of the property, not null
230
         */
231
        public void evict(String propertyName) {
232
                if (OpenmrsConstants.GP_CASE_SENSITIVE_DATABASE_STRING_COMPARISON.equalsIgnoreCase(propertyName)) {
1 ✔
233
                        // changes what the DAO returns for every spelling of every name
234
                        clear();
1 ✔
235
                } else {
236
                        invalidate(key(propertyName));
1 ✔
237
                }
238
        }
1 ✔
239

240
        /**
241
         * Evicts every property. As with {@link #evict(String)}, within a transaction this happens when the
242
         * transaction completes, and until then the transaction reads every property without the cache.
243
         */
244
        public void clear() {
245
                invalidate(ALL_PROPERTIES);
1 ✔
246
        }
1 ✔
247

248
        /**
249
         * Clears the cache whenever the application context is refreshed, which happens whenever modules
250
         * are started or stopped.
251
         */
252
        @Override
253
        public void onApplicationEvent(ContextRefreshedEvent event) {
254
                clear();
1 ✔
255
        }
1 ✔
256

257
        /**
258
         * Waits for the fills in progress to finish. Fills only affect what later calls find in the cache,
259
         * so this is only needed where a caller must observe a fill, for example in tests.
260
         */
261
        void awaitFills() {
262
                fills.values().forEach(CompletableFuture::join);
1 ✔
263
        }
1 ✔
264

265
        /**
266
         * Evicts every property immediately, even within a transaction, which still reads every property
267
         * without the cache until it completes. Only for tests that load data behind the API.
268
         */
269
        void clearNow() {
270
                Cache<Object, Object> cache = getCache();
1 ✔
271
                if (cache == null) {
1 ✔
NEW
272
                        return;
×
273
                }
274

275
                evictOrClearLocally(cache, Collections.singleton(ALL_PROPERTIES));
1 ✔
276
                if (TransactionSynchronizationManager.isSynchronizationActive()) {
1 ✔
277
                        getWrittenInCurrentTransaction(cache).add(ALL_PROPERTIES);
1 ✔
278
                }
279
        }
1 ✔
280

281
        /**
282
         * Forgets which properties the current transaction has written, so that it reads through the cache
283
         * again and does not evict them when it completes. Only for tests that load data behind the API but
284
         * still need to observe cache hits.
285
         */
286
        void forgetWritesInCurrentTransaction() {
287
                if (TransactionSynchronizationManager.hasResource(this)) {
1 ✔
288
                        ((Set<?>) TransactionSynchronizationManager.getResource(this)).clear();
1 ✔
289
                }
290
        }
1 ✔
291

292
        /**
293
         * Starts a fill unless one is already in progress for the property or {@link #MAX_CONCURRENT_FILLS}
294
         * are running. The in-progress marker is published before the fill starts and only that marker is
295
         * removed when it finishes, so the fill may run on any thread, including this one.
296
         */
297
        private void startFill(String propertyName) {
298
                if (!fillPermits.tryAcquire()) {
1 ✔
299
                        return;
1 ✔
300
                }
301

302
                CompletableFuture<Void> marker = new CompletableFuture<>();
1 ✔
303
                if (fills.putIfAbsent(propertyName, marker) != null) {
1 ✔
304
                        fillPermits.release();
1 ✔
305
                        return;
1 ✔
306
                }
307

308
                try {
309
                        backgroundRunner.accept(() -> {
1 ✔
310
                                try {
311
                                        fill(propertyName);
1 ✔
NEW
312
                                } catch (RuntimeException e) {
×
NEW
313
                                        log.warn("Could not fill the global property cache with {}", propertyName, e);
×
314
                                } finally {
315
                                        finish(propertyName, marker);
1 ✔
316
                                }
317
                        });
1 ✔
318
                } catch (RuntimeException e) {
1 ✔
319
                        log.warn("Could not start a fill of the global property cache with {}", propertyName, e);
1 ✔
320
                        finish(propertyName, marker);
1 ✔
321
                }
1 ✔
322
        }
1 ✔
323

324
        private void finish(String propertyName, CompletableFuture<Void> marker) {
325
                fills.remove(propertyName, marker);
1 ✔
326
                fillPermits.release();
1 ✔
327
                marker.complete(null);
1 ✔
328
        }
1 ✔
329

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

341
                Object loadedAt = currentGeneration(cache);
1 ✔
342
                readOnlyTransaction.executeWithoutResult(status -> {
1 ✔
343
                        GlobalProperty property = dao.getGlobalPropertyObject(propertyName);
1 ✔
344
                        if (property != null) {
1 ✔
345
                                String storedName = dao.getStoredGlobalPropertyName(propertyName);
1 ✔
346
                                if (storedName == null || !key(storedName).equals(key(propertyName))) {
1 ✔
347
                                        return;
1 ✔
348
                                }
349
                        }
350
                        putIfCurrent(cache, key(propertyName), new CachedEntry(propertyName, Entry.of(property)), loadedAt);
1 ✔
351
                });
1 ✔
352
        }
1 ✔
353

354
        /**
355
         * Returns this node's generation token, first creating one if there is none. The token is created
356
         * on this node only, since writing it cluster-wide would invalidate the other nodes' tokens.
357
         */
358
        private static Object currentGeneration(Cache<Object, Object> cache) {
359
                Object generation = cache.get(GENERATION);
1 ✔
360
                if (generation != null) {
1 ✔
361
                        return generation;
1 ✔
362
                }
363

364
                String created = UUID.randomUUID().toString();
1 ✔
365
                Object existing = cache.getAdvancedCache().withFlags(Flag.CACHE_MODE_LOCAL).putIfAbsent(GENERATION, created);
1 ✔
366
                return existing != null ? existing : created;
1 ✔
367
        }
368

369
        /**
370
         * Caches <code>value</code> unless an eviction has happened since it was loaded. An eviction
371
         * replaces the generation token before removing entries, so if one runs while the value is being
372
         * put, the second check either sees it and removes the value, or the eviction removes it. The
373
         * removal is local, since the value was only ever written on this node.
374
         */
375
        private static void putIfCurrent(Cache<Object, Object> cache, String key, CachedEntry value, Object loadedAt) {
376
                if (!loadedAt.equals(cache.get(GENERATION))) {
1 ✔
377
                        return;
1 ✔
378
                }
379

380
                cache.putForExternalRead(key, value);
1 ✔
381
                if (!loadedAt.equals(cache.get(GENERATION))) {
1 ✔
382
                        cache.getAdvancedCache().withFlags(Flag.CACHE_MODE_LOCAL).remove(key);
1 ✔
383
                }
384
        }
1 ✔
385

386
        private static String key(String propertyName) {
387
                return propertyName.toLowerCase(Locale.ROOT);
1 ✔
388
        }
389

390
        /**
391
         * Evicts <code>key</code> when the current transaction completes, or immediately outside one. Keys
392
         * recorded by one transaction are evicted together.
393
         */
394
        private void invalidate(String key) {
395
                Cache<Object, Object> cache = getCache();
1 ✔
396
                if (cache == null) {
1 ✔
NEW
397
                        return;
×
398
                }
399

400
                if (TransactionSynchronizationManager.isSynchronizationActive()) {
1 ✔
401
                        getWrittenInCurrentTransaction(cache).add(key);
1 ✔
402
                } else {
403
                        evictOrClearLocally(cache, Collections.singleton(key));
1 ✔
404
                }
405
        }
1 ✔
406

407
        /**
408
         * Replaces the generation token and then removes the keys, or every entry if they include
409
         * {@link #ALL_PROPERTIES}. The token must be replaced first: a clear does not lock every key, so a
410
         * fill could otherwise write behind it and still find its token. Replacing the token with a plain
411
         * put invalidates it on every other node before the removals are sent. The removals are sent
412
         * together, so evicting several keys costs about one round trip to the other nodes.
413
         */
414
        /**
415
         * Evicts the keys on every node or, if that fails, for example because the cluster is partitioned,
416
         * clears this node's cache instead, so that at least this node does not serve values from before
417
         * the write. Nodes the eviction did not reach may serve them until the lifespan expires, or, if
418
         * they were cut off by a partition, until it heals.
419
         */
420
        private static void evictOrClearLocally(Cache<Object, Object> cache, Set<String> keys) {
421
                try {
422
                        evictNow(cache, keys);
1 ✔
423
                } catch (RuntimeException e) {
1 ✔
424
                        log.error("Could not evict {} from the global property cache on every node, so clearing it on this node only",
1 ✔
425
                            keys.contains(ALL_PROPERTIES) ? "every property" : keys, e);
1 ✔
426
                        clearLocally(cache);
1 ✔
427
                }
1 ✔
428
        }
1 ✔
429

430
        /**
431
         * Clears this node's entries, including its generation token, so that fills in progress on this
432
         * node are discarded too.
433
         */
434
        private static void clearLocally(Cache<Object, Object> cache) {
435
                cache.getAdvancedCache().withFlags(Flag.CACHE_MODE_LOCAL).clear();
1 ✔
436
        }
1 ✔
437

438
        private static void evictNow(Cache<Object, Object> cache, Set<String> keys) {
439
                cache.put(GENERATION, UUID.randomUUID().toString());
1 ✔
440
                if (keys.contains(ALL_PROPERTIES)) {
1 ✔
441
                        cache.clear();
1 ✔
442
                } else {
443
                        CompletableFuture.allOf(keys.stream().map(cache::removeAsync).toArray(CompletableFuture[]::new)).join();
1 ✔
444
                }
445
        }
1 ✔
446

447
        private boolean isWrittenInCurrentTransaction(String propertyName) {
448
                if (!TransactionSynchronizationManager.hasResource(this)) {
1 ✔
449
                        return false;
1 ✔
450
                }
451

452
                Set<?> written = (Set<?>) TransactionSynchronizationManager.getResource(this);
1 ✔
453
                return written.contains(ALL_PROPERTIES) || written.contains(key(propertyName));
1 ✔
454
        }
455

456
        /**
457
         * Returns the keys the current transaction has written, first arranging for them to be evicted when
458
         * it completes. The set is unbound while the transaction is suspended, so a transaction started in
459
         * the meantime records and evicts its own writes.
460
         */
461
        @SuppressWarnings("unchecked")
462
        private Set<String> getWrittenInCurrentTransaction(Cache<Object, Object> cache) {
463
                if (TransactionSynchronizationManager.hasResource(this)) {
1 ✔
464
                        return (Set<String>) TransactionSynchronizationManager.getResource(this);
1 ✔
465
                }
466

467
                Set<String> written = new HashSet<>();
1 ✔
468
                TransactionSynchronizationManager.bindResource(this, written);
1 ✔
469
                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
1 ✔
470

471
                        @Override
472
                        public void suspend() {
473
                                TransactionSynchronizationManager.unbindResource(GlobalPropertyCache.this);
1 ✔
474
                        }
1 ✔
475

476
                        @Override
477
                        public void resume() {
478
                                TransactionSynchronizationManager.bindResource(GlobalPropertyCache.this, written);
1 ✔
479
                        }
1 ✔
480

481
                        @Override
482
                        public void afterCompletion(int status) {
483
                                try {
484
                                        if (!written.isEmpty()) {
1 ✔
485
                                                evictOrClearLocally(cache, written);
1 ✔
486
                                        }
487
                                } finally {
488
                                        TransactionSynchronizationManager.unbindResourceIfPossible(GlobalPropertyCache.this);
1 ✔
489
                                }
490
                        }
1 ✔
491
                });
492
                return written;
1 ✔
493
        }
494

495
        @SuppressWarnings("unchecked")
496
        private Cache<Object, Object> getCache() {
497
                org.springframework.cache.Cache cache = cacheManager.getCache(CACHE_NAME);
1 ✔
498
                return cache == null ? null : (Cache<Object, Object>) cache.getNativeCache();
1 ✔
499
        }
500

501
        /**
502
         * Clears this node's cache when a network partition heals, since while it was cut off it missed the
503
         * evictions of writes made on the other side. Public only because Infinispan requires listeners to
504
         * be.
505
         */
506
        @Listener
507
        public static final class PartitionMergeListener {
508

509
                private final GlobalPropertyCache owner;
510

511
                PartitionMergeListener(GlobalPropertyCache owner) {
1 ✔
512
                        this.owner = owner;
1 ✔
513
                }
1 ✔
514

515
                @Merged
516
                public void merged(MergeEvent event) {
517
                        Cache<Object, Object> cache = owner.getCache();
1 ✔
518
                        if (cache != null) {
1 ✔
519
                                clearLocally(cache);
1 ✔
520
                        }
521
                }
1 ✔
522
        }
523

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

527
                private static final long serialVersionUID = 1L;
528

529
                private final String propertyName;
530

531
                private final Entry entry;
532

533
                CachedEntry(String propertyName, Entry entry) {
1 ✔
534
                        this.propertyName = propertyName;
1 ✔
535
                        this.entry = entry;
1 ✔
536
                }
1 ✔
537

538
                boolean isFor(String propertyName) {
539
                        return this.propertyName.equals(propertyName);
1 ✔
540
                }
541

542
                Entry getEntry() {
543
                        return entry;
1 ✔
544
                }
545
        }
546

547
        /**
548
         * An immutable snapshot of a global property's value and view privilege, or {@link #ABSENT} if the
549
         * property does not exist.
550
         */
551
        public static final class Entry implements Serializable {
552

553
                private static final long serialVersionUID = 1L;
554

555
                public static final Entry ABSENT = new Entry(false, null, null);
1 ✔
556

557
                private final boolean present;
558

559
                private final String value;
560

561
                private final String viewPrivilege;
562

563
                private Entry(boolean present, String value, String viewPrivilege) {
1 ✔
564
                        this.present = present;
1 ✔
565
                        this.value = value;
1 ✔
566
                        this.viewPrivilege = viewPrivilege;
1 ✔
567
                }
1 ✔
568

569
                /**
570
                 * @param globalProperty the property to snapshot, or null if it does not exist
571
                 * @return a snapshot of <code>globalProperty</code>, or {@link #ABSENT} if it is null
572
                 */
573
                public static Entry of(GlobalProperty globalProperty) {
574
                        if (globalProperty == null) {
1 ✔
575
                                return ABSENT;
1 ✔
576
                        }
577

578
                        String viewPrivilege = globalProperty.getViewPrivilege() == null ? null
1 ✔
579
                                : globalProperty.getViewPrivilege().getPrivilege();
1 ✔
580
                        return new Entry(true, globalProperty.getPropertyValue(), viewPrivilege);
1 ✔
581
                }
582

583
                /**
584
                 * @return true if the property exists
585
                 */
586
                public boolean isPresent() {
587
                        return present;
1 ✔
588
                }
589

590
                /**
591
                 * @return the property's value, or null if it has none or does not exist
592
                 */
593
                public String getValue() {
594
                        return value;
1 ✔
595
                }
596

597
                /**
598
                 * @return the name of the privilege needed to view the property, or null if none is needed
599
                 */
600
                public String getViewPrivilege() {
601
                        return viewPrivilege;
1 ✔
602
                }
603
        }
604
}
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