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

grpc / grpc-java / #18734

pending completion
#18734

push

github-actions

web-flow
Have rls's LRU Cache rely on cleanup process to remove expired entries (#10400)

* Add test for multiple targets with cache expiration.

29153 of 33045 relevant lines covered (88.22%)

0.88 hits per line

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

90.12
/../rls/src/main/java/io/grpc/rls/LinkedHashLruCache.java
1
/*
2
 * Copyright 2020 The gRPC Authors
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16

17
package io.grpc.rls;
18

19
import static com.google.common.base.Preconditions.checkArgument;
20
import static com.google.common.base.Preconditions.checkNotNull;
21
import static com.google.common.base.Preconditions.checkState;
22

23
import com.google.common.base.MoreObjects;
24
import com.google.common.base.Ticker;
25
import java.util.ArrayList;
26
import java.util.Collections;
27
import java.util.Iterator;
28
import java.util.LinkedHashMap;
29
import java.util.List;
30
import java.util.Map;
31
import java.util.Objects;
32
import java.util.concurrent.ScheduledExecutorService;
33
import java.util.concurrent.ScheduledFuture;
34
import java.util.concurrent.TimeUnit;
35
import java.util.concurrent.atomic.AtomicLong;
36
import javax.annotation.CheckReturnValue;
37
import javax.annotation.Nullable;
38
import javax.annotation.concurrent.GuardedBy;
39
import javax.annotation.concurrent.ThreadSafe;
40

41
/**
42
 * A LinkedHashLruCache implements least recently used caching where it supports access order lru
43
 * cache eviction while allowing entry level expiration time. When the cache reaches max capacity,
44
 * LruCache try to remove up to one already expired entries. If it doesn't find any expired entries,
45
 * it will remove based on access order of entry. On top of this, LruCache also proactively removes
46
 * expired entries based on configured time interval.
47
 */
48
@ThreadSafe
49
abstract class LinkedHashLruCache<K, V> implements LruCache<K, V> {
50

51
  private final Object lock;
52

53
  @GuardedBy("lock")
54
  private final LinkedHashMap<K, SizedValue> delegate;
55
  private final PeriodicCleaner periodicCleaner;
56
  private final Ticker ticker;
57
  private final EvictionListener<K, SizedValue> evictionListener;
58
  private final AtomicLong estimatedSizeBytes = new AtomicLong();
1✔
59
  private long estimatedMaxSizeBytes;
60

61
  LinkedHashLruCache(
62
      final long estimatedMaxSizeBytes,
63
      @Nullable final EvictionListener<K, V> evictionListener,
64
      int cleaningInterval,
65
      TimeUnit cleaningIntervalUnit,
66
      ScheduledExecutorService ses,
67
      final Ticker ticker,
68
      Object lock) {
1✔
69
    checkState(estimatedMaxSizeBytes > 0, "max estimated cache size should be positive");
1✔
70
    this.estimatedMaxSizeBytes = estimatedMaxSizeBytes;
1✔
71
    this.lock = checkNotNull(lock, "lock");
1✔
72
    this.evictionListener = new SizeHandlingEvictionListener(evictionListener);
1✔
73
    this.ticker = checkNotNull(ticker, "ticker");
1✔
74
    delegate = new LinkedHashMap<K, SizedValue>(
1✔
75
        // rough estimate or minimum hashmap default
76
        Math.max((int) (estimatedMaxSizeBytes / 1000), 16),
1✔
77
        /* loadFactor= */ 0.75f,
78
        /* accessOrder= */ true) {
1✔
79
      @Override
80
      protected boolean removeEldestEntry(Map.Entry<K, SizedValue> eldest) {
81
        if (estimatedSizeBytes.get() <= LinkedHashLruCache.this.estimatedMaxSizeBytes) {
1✔
82
          return false;
1✔
83
        }
84

85
        // first, remove at most 1 expired entry
86
        boolean removed = cleanupExpiredEntries(1, ticker.read());
1✔
87
        // handles size based eviction if necessary no expired entry
88
        boolean shouldRemove =
1✔
89
            !removed && shouldInvalidateEldestEntry(eldest.getKey(), eldest.getValue().value);
1✔
90
        if (shouldRemove) {
1✔
91
          // remove entry by us to make sure lruIterator and cache is in sync
92
          LinkedHashLruCache.this.invalidate(eldest.getKey(), EvictionType.SIZE);
1✔
93
        }
94
        return false;
1✔
95
      }
96
    };
97
    periodicCleaner = new PeriodicCleaner(ses, cleaningInterval, cleaningIntervalUnit).start();
1✔
98
  }
1✔
99

100
  /**
101
   * Determines if the eldest entry should be kept or not when the cache size limit is reached. Note
102
   * that LruCache is access level and the eldest is determined by access pattern.
103
   */
104
  @SuppressWarnings("unused")
105
  protected boolean shouldInvalidateEldestEntry(K eldestKey, V eldestValue) {
106
    return true;
1✔
107
  }
108

109
  /** Determines if the entry is already expired or not. */
110
  protected abstract boolean isExpired(K key, V value, long nowNanos);
111

112
  /**
113
   * Returns estimated size of entry to keep track. If it always returns 1, the max size bytes
114
   * behaves like max number of entry (default behavior).
115
   */
116
  @SuppressWarnings("unused")
117
  protected int estimateSizeOf(K key, V value) {
118
    return 1;
×
119
  }
120

121
  protected long estimatedMaxSizeBytes() {
122
    return estimatedMaxSizeBytes;
×
123
  }
124

125
  /** Updates size for given key if entry exists. It is useful if the cache value is mutated. */
126
  public void updateEntrySize(K key) {
127
    synchronized (lock) {
1✔
128
      SizedValue entry = readInternal(key);
1✔
129
      if (entry == null) {
1✔
130
        return;
×
131
      }
132
      int prevSize = entry.size;
1✔
133
      int newSize = estimateSizeOf(key, entry.value);
1✔
134
      entry.size = newSize;
1✔
135
      estimatedSizeBytes.addAndGet(newSize - prevSize);
1✔
136
    }
1✔
137
  }
1✔
138

139
  /**
140
   * Returns estimated cache size bytes. Each entry size is calculated by {@link
141
   * #estimateSizeOf(java.lang.Object, java.lang.Object)}.
142
   */
143
  public long estimatedSizeBytes() {
144
    return estimatedSizeBytes.get();
1✔
145
  }
146

147
  @Override
148
  @Nullable
149
  public final V cache(K key, V value) {
150
    checkNotNull(key, "key");
1✔
151
    checkNotNull(value, "value");
1✔
152
    SizedValue existing;
153
    int size = estimateSizeOf(key, value);
1✔
154
    synchronized (lock) {
1✔
155
      estimatedSizeBytes.addAndGet(size);
1✔
156
      existing = delegate.put(key, new SizedValue(size, value));
1✔
157
      if (existing != null) {
1✔
158
        evictionListener.onEviction(key, existing, EvictionType.REPLACED);
1✔
159
      }
160
    }
1✔
161
    return existing == null ? null : existing.value;
1✔
162
  }
163

164
  @Override
165
  @Nullable
166
  @CheckReturnValue
167
  public final V read(K key) {
168
    SizedValue entry = readInternal(key);
1✔
169
    if (entry != null) {
1✔
170
      return entry.value;
1✔
171
    }
172
    return null;
1✔
173
  }
174

175
  @Nullable
176
  @CheckReturnValue
177
  private SizedValue readInternal(K key) {
178
    checkNotNull(key, "key");
1✔
179
    synchronized (lock) {
1✔
180
      SizedValue existing = delegate.get(key);
1✔
181
      if (existing != null && isExpired(key, existing.value, ticker.read())) {
1✔
182
        return null;
×
183
      }
184
      return existing;
1✔
185
    }
186
  }
187

188
  @Override
189
  @Nullable
190
  public final V invalidate(K key) {
191
    return invalidate(key, EvictionType.EXPLICIT);
1✔
192
  }
193

194
  @Nullable
195
  private V invalidate(K key, EvictionType cause) {
196
    checkNotNull(key, "key");
1✔
197
    checkNotNull(cause, "cause");
1✔
198
    synchronized (lock) {
1✔
199
      SizedValue existing = delegate.remove(key);
1✔
200
      if (existing != null) {
1✔
201
        evictionListener.onEviction(key, existing, cause);
1✔
202
      }
203
      return existing == null ? null : existing.value;
1✔
204
    }
205
  }
206

207
  @Override
208
  public final void invalidateAll() {
209
    synchronized (lock) {
1✔
210
      Iterator<Map.Entry<K, SizedValue>> iterator = delegate.entrySet().iterator();
1✔
211
      while (iterator.hasNext()) {
1✔
212
        Map.Entry<K, SizedValue> entry = iterator.next();
1✔
213
        if (entry.getValue() != null) {
1✔
214
          evictionListener.onEviction(entry.getKey(), entry.getValue(), EvictionType.EXPLICIT);
1✔
215
        }
216
        iterator.remove();
1✔
217
      }
1✔
218
    }
1✔
219
  }
1✔
220

221
  @Override
222
  @CheckReturnValue
223
  public final boolean hasCacheEntry(K key) {
224
    // call readInternal to filter already expired entry in the cache
225
    return readInternal(key) != null;
1✔
226
  }
227

228
  /** Returns shallow copied values in the cache. */
229
  public final List<V> values() {
230
    synchronized (lock) {
1✔
231
      List<V> list = new ArrayList<>(delegate.size());
1✔
232
      for (SizedValue value : delegate.values()) {
1✔
233
        list.add(value.value);
1✔
234
      }
1✔
235
      return Collections.unmodifiableList(list);
1✔
236
    }
237
  }
238

239
  protected long now() {
240
    return ticker.read();
1✔
241
  }
242

243
  /**
244
   * Cleans up cache if needed to fit into max size bytes by
245
   * removing expired entries and removing oldest entries by LRU order.
246
   * Returns TRUE if any unexpired entries were removed
247
   */
248
  protected final boolean fitToLimit() {
249
    boolean removedAnyUnexpired = false;
1✔
250
    synchronized (lock) {
1✔
251
      if (estimatedSizeBytes.get() <= estimatedMaxSizeBytes) {
1✔
252
        // new size is larger no need to do cleanup
253
        return false;
1✔
254
      }
255
      // cleanup expired entries
256
      cleanupExpiredEntries(now());
1✔
257

258
      // cleanup eldest entry until new size limit
259
      Iterator<Map.Entry<K, SizedValue>> lruIter = delegate.entrySet().iterator();
1✔
260
      while (lruIter.hasNext() && estimatedMaxSizeBytes < this.estimatedSizeBytes.get()) {
1✔
261
        Map.Entry<K, SizedValue> entry = lruIter.next();
1✔
262
        if (!shouldInvalidateEldestEntry(entry.getKey(), entry.getValue().value)) {
1✔
263
          break; // Violates some constraint like minimum age so stop our cleanup
×
264
        }
265
        lruIter.remove();
1✔
266
        // eviction listener will update the estimatedSizeBytes
267
        evictionListener.onEviction(entry.getKey(), entry.getValue(), EvictionType.SIZE);
1✔
268
        removedAnyUnexpired = true;
1✔
269
      }
1✔
270
    }
1✔
271
    return removedAnyUnexpired;
1✔
272
  }
273

274
  /**
275
   * Resizes cache. If new size is smaller than current estimated size, it will free up space by
276
   * removing expired entries and removing oldest entries by LRU order.
277
   */
278
  public final void resize(long newSizeBytes) {
279
    synchronized (lock) {
1✔
280
      this.estimatedMaxSizeBytes = newSizeBytes;
1✔
281
      fitToLimit();
1✔
282
    }
1✔
283
  }
1✔
284

285
  @Override
286
  @CheckReturnValue
287
  public final int estimatedSize() {
288
    synchronized (lock) {
1✔
289
      return delegate.size();
1✔
290
    }
291
  }
292

293
  private boolean cleanupExpiredEntries(long now) {
294
    return cleanupExpiredEntries(Integer.MAX_VALUE, now);
1✔
295
  }
296

297
  // maxExpiredEntries is by number of entries
298
  private boolean cleanupExpiredEntries(int maxExpiredEntries, long now) {
299
    checkArgument(maxExpiredEntries > 0, "maxExpiredEntries must be positive");
1✔
300
    boolean removedAny = false;
1✔
301
    synchronized (lock) {
1✔
302
      Iterator<Map.Entry<K, SizedValue>> lruIter = delegate.entrySet().iterator();
1✔
303
      while (lruIter.hasNext() && maxExpiredEntries > 0) {
1✔
304
        Map.Entry<K, SizedValue> entry = lruIter.next();
1✔
305
        if (isExpired(entry.getKey(), entry.getValue().value, now)) {
1✔
306
          lruIter.remove();
1✔
307
          evictionListener.onEviction(entry.getKey(), entry.getValue(), EvictionType.EXPIRED);
1✔
308
          removedAny = true;
1✔
309
          maxExpiredEntries--;
1✔
310
        }
311
      }
1✔
312
    }
1✔
313
    return removedAny;
1✔
314
  }
315

316
  @Override
317
  public final void close() {
318
    synchronized (lock) {
1✔
319
      periodicCleaner.stop();
1✔
320
      invalidateAll();
1✔
321
    }
1✔
322
  }
1✔
323

324
  /** Periodically cleans up the AsyncRequestCache. */
325
  private final class PeriodicCleaner {
326

327
    private final ScheduledExecutorService ses;
328
    private final int interval;
329
    private final TimeUnit intervalUnit;
330
    private ScheduledFuture<?> scheduledFuture;
331

332
    PeriodicCleaner(ScheduledExecutorService ses, int interval, TimeUnit intervalUnit) {
1✔
333
      this.ses = checkNotNull(ses, "ses");
1✔
334
      checkState(interval > 0, "interval must be positive");
1✔
335
      this.interval = interval;
1✔
336
      this.intervalUnit = checkNotNull(intervalUnit, "intervalUnit");
1✔
337
    }
1✔
338

339
    PeriodicCleaner start() {
340
      checkState(scheduledFuture == null, "cleaning task can be started only once");
1✔
341
      this.scheduledFuture =
1✔
342
          ses.scheduleAtFixedRate(new CleaningTask(), interval, interval, intervalUnit);
1✔
343
      return this;
1✔
344
    }
345

346
    void stop() {
347
      if (scheduledFuture != null) {
1✔
348
        scheduledFuture.cancel(false);
1✔
349
        scheduledFuture = null;
1✔
350
      }
351
    }
1✔
352

353
    private class CleaningTask implements Runnable {
1✔
354

355
      @Override
356
      public void run() {
357
        cleanupExpiredEntries(ticker.read());
1✔
358
      }
1✔
359
    }
360
  }
361

362
  /** A {@link EvictionListener} keeps track of size. */
363
  private final class SizeHandlingEvictionListener implements EvictionListener<K, SizedValue> {
364

365
    private final EvictionListener<K, V> delegate;
366

367
    SizeHandlingEvictionListener(@Nullable EvictionListener<K, V> delegate) {
1✔
368
      this.delegate = delegate;
1✔
369
    }
1✔
370

371
    @Override
372
    public void onEviction(K key, SizedValue value, EvictionType cause) {
373
      estimatedSizeBytes.addAndGet(-1L * value.size);
1✔
374
      if (delegate != null) {
1✔
375
        delegate.onEviction(key, value.value, cause);
1✔
376
      }
377
    }
1✔
378
  }
379

380
  private final class SizedValue {
381
    volatile int size;
382
    final V value;
383

384
    SizedValue(int size, V value) {
1✔
385
      this.size = size;
1✔
386
      this.value = value;
1✔
387
    }
1✔
388

389
    @Override
390
    public boolean equals(Object o) {
391
      // NOTE: the size doesn't affect equality
392
      if (this == o) {
×
393
        return true;
×
394
      }
395
      if (o == null || getClass() != o.getClass()) {
×
396
        return false;
×
397
      }
398
      LinkedHashLruCache<?, ?>.SizedValue that = (LinkedHashLruCache<?, ?>.SizedValue) o;
×
399
      return Objects.equals(value, that.value);
×
400
    }
401

402
    @Override
403
    public int hashCode() {
404
      // NOTE: the size doesn't affect hashCode
405
      return Objects.hash(value);
×
406
    }
407

408
    @Override
409
    public String toString() {
410
      return MoreObjects.toStringHelper(this)
×
411
          .add("size", size)
×
412
          .add("value", value)
×
413
          .toString();
×
414
    }
415
  }
416
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc