Skip to content

Commit 1e1bf28

Browse files
authored
Testing indices query cache memory stats (#135298)
1 parent de92d49 commit 1e1bf28

File tree

2 files changed

+197
-12
lines changed

2 files changed

+197
-12
lines changed

server/src/main/java/org/elasticsearch/indices/IndicesQueryCache.java

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,19 @@ private static QueryCacheStats toQueryCacheStatsSafe(@Nullable Stats stats) {
8989
return stats == null ? new QueryCacheStats() : stats.toQueryCacheStats();
9090
}
9191

92-
private long getShareOfAdditionalRamBytesUsed(long cacheSize) {
92+
private long getShareOfAdditionalRamBytesUsed(long itemsInCacheForShard) {
9393
if (sharedRamBytesUsed == 0L) {
9494
return 0L;
9595
}
9696

97-
// We also have some shared ram usage that we try to distribute proportionally to the cache footprint of each shard.
97+
/*
98+
* We have some shared ram usage that we try to distribute proportionally to the number of segment-requests in the cache for each
99+
* shard.
100+
*/
98101
// TODO avoid looping over all local shards here - see https://github.com/elastic/elasticsearch/issues/97222
99-
long totalSize = 0L;
102+
long totalItemsInCache = 0L;
100103
int shardCount = 0;
101-
if (cacheSize == 0L) {
104+
if (itemsInCacheForShard == 0L) {
102105
for (final var stats : shardStats.values()) {
103106
shardCount += 1;
104107
if (stats.cacheSize > 0L) {
@@ -110,7 +113,7 @@ private long getShareOfAdditionalRamBytesUsed(long cacheSize) {
110113
// branchless loop for the common case
111114
for (final var stats : shardStats.values()) {
112115
shardCount += 1;
113-
totalSize += stats.cacheSize;
116+
totalItemsInCache += stats.cacheSize;
114117
}
115118
}
116119

@@ -121,12 +124,20 @@ private long getShareOfAdditionalRamBytesUsed(long cacheSize) {
121124
}
122125

123126
final long additionalRamBytesUsed;
124-
if (totalSize == 0) {
127+
if (totalItemsInCache == 0) {
125128
// all shards have zero cache footprint, so we apportion the size of the shared bytes equally across all shards
126129
additionalRamBytesUsed = Math.round((double) sharedRamBytesUsed / shardCount);
127130
} else {
128-
// some shards have nonzero cache footprint, so we apportion the size of the shared bytes proportionally to cache footprint
129-
additionalRamBytesUsed = Math.round((double) sharedRamBytesUsed * cacheSize / totalSize);
131+
/*
132+
* Some shards have nonzero cache footprint, so we apportion the size of the shared bytes proportionally to the number of
133+
* segment-requests in the cache for this shard (the number and size of documents associated with those requests is irrelevant
134+
* for this calculation).
135+
* Note that this was a somewhat arbitrary decision. Calculating it by number of documents might have been better. Calculating
136+
* it by number of documents weighted by size would also be good, but possibly more expensive. But the decision to attribute
137+
* memory proportionally to the number of segment-requests was made a long time ago, and we're sticking with that here for the
138+
* sake of consistency and backwards compatibility.
139+
*/
140+
additionalRamBytesUsed = Math.round((double) sharedRamBytesUsed * itemsInCacheForShard / totalItemsInCache);
130141
}
131142
assert additionalRamBytesUsed >= 0L : additionalRamBytesUsed;
132143
return additionalRamBytesUsed;

server/src/test/java/org/elasticsearch/indices/IndicesQueryCacheTests.java

Lines changed: 178 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.apache.lucene.search.ScorerSupplier;
2727
import org.apache.lucene.search.Weight;
2828
import org.apache.lucene.store.Directory;
29+
import org.apache.lucene.util.Accountable;
2930
import org.elasticsearch.common.lucene.index.ElasticsearchDirectoryReader;
3031
import org.elasticsearch.common.settings.Settings;
3132
import org.elasticsearch.core.IOUtils;
@@ -34,26 +35,44 @@
3435
import org.elasticsearch.index.shard.ShardId;
3536
import org.elasticsearch.test.ESTestCase;
3637

38+
import java.io.Closeable;
3739
import java.io.IOException;
40+
import java.util.ArrayList;
41+
import java.util.List;
42+
import java.util.concurrent.atomic.AtomicReference;
43+
44+
import static org.hamcrest.Matchers.closeTo;
45+
import static org.hamcrest.Matchers.equalTo;
46+
import static org.hamcrest.Matchers.lessThan;
3847

3948
public class IndicesQueryCacheTests extends ESTestCase {
4049

41-
private static class DummyQuery extends Query {
50+
private static class DummyQuery extends Query implements Accountable {
4251

43-
private final int id;
52+
private final String id;
53+
private final long sizeInCache;
4454

4555
DummyQuery(int id) {
56+
this(Integer.toString(id), 10);
57+
}
58+
59+
DummyQuery(String id) {
60+
this(id, 10);
61+
}
62+
63+
DummyQuery(String id, long sizeInCache) {
4664
this.id = id;
65+
this.sizeInCache = sizeInCache;
4766
}
4867

4968
@Override
5069
public boolean equals(Object obj) {
51-
return sameClassAs(obj) && id == ((DummyQuery) obj).id;
70+
return sameClassAs(obj) && id.equals(((DummyQuery) obj).id);
5271
}
5372

5473
@Override
5574
public int hashCode() {
56-
return 31 * classHash() + id;
75+
return 31 * classHash() + id.hashCode();
5776
}
5877

5978
@Override
@@ -82,6 +101,10 @@ public boolean isCacheable(LeafReaderContext ctx) {
82101
};
83102
}
84103

104+
@Override
105+
public long ramBytesUsed() {
106+
return sizeInCache;
107+
}
85108
}
86109

87110
public void testBasics() throws IOException {
@@ -404,4 +427,155 @@ public void testDelegatesScorerSupplier() throws Exception {
404427
cache.onClose(shard);
405428
cache.close();
406429
}
430+
431+
public void testGetStatsMemory() throws Exception {
432+
/*
433+
* This test creates 2 shards, one with two segments and one with one. It makes unique queries against all 3 segments (so that each
434+
* query will be cached, up to the max cache size), and then asserts various things about the cache memory. Most importantly, it
435+
* asserts that the memory the cache attributes to each shard is proportional to the number of segment-queries for the shard in the
436+
* cache (and not to the number of documents in the query).
437+
*/
438+
String indexName = randomIdentifier();
439+
String uuid = randomUUID();
440+
ShardId shard1 = new ShardId(indexName, uuid, 0);
441+
ShardId shard2 = new ShardId(indexName, uuid, 1);
442+
List<Closeable> closeableList = new ArrayList<>();
443+
// We're going to create 2 segments for shard1, and 1 segment for shard2:
444+
int shard1Segment1Docs = randomIntBetween(11, 1000);
445+
int shard1Segment2Docs = randomIntBetween(1, 10);
446+
int shard2Segment1Docs = randomIntBetween(1, 10);
447+
IndexSearcher shard1Segment1Searcher = initializeSegment(shard1, shard1Segment1Docs, closeableList);
448+
IndexSearcher shard1Segment2Searcher = initializeSegment(shard1, shard1Segment2Docs, closeableList);
449+
IndexSearcher shard2Searcher = initializeSegment(shard2, shard2Segment1Docs, closeableList);
450+
451+
final int maxCacheSize = 200;
452+
Settings settings = Settings.builder()
453+
.put(IndicesQueryCache.INDICES_CACHE_QUERY_COUNT_SETTING.getKey(), maxCacheSize)
454+
.put(IndicesQueryCache.INDICES_QUERIES_CACHE_ALL_SEGMENTS_SETTING.getKey(), true)
455+
.build();
456+
IndicesQueryCache cache = new IndicesQueryCache(settings);
457+
shard1Segment1Searcher.setQueryCache(cache);
458+
shard1Segment2Searcher.setQueryCache(cache);
459+
shard2Searcher.setQueryCache(cache);
460+
461+
assertEquals(0L, cache.getStats(shard1).getMemorySizeInBytes());
462+
463+
final long largeQuerySize = randomIntBetween(100, 1000);
464+
final long smallQuerySize = randomIntBetween(10, 50);
465+
466+
final int shard1Queries = randomIntBetween(20, 50);
467+
final int shard2Queries = randomIntBetween(5, 10);
468+
469+
for (int i = 0; i < shard1Queries; ++i) {
470+
shard1Segment1Searcher.count(new DummyQuery("ingest1-" + i, largeQuerySize));
471+
}
472+
long shard1Segment1CacheMemory = calculateActualCacheMemoryForSegment(shard1Queries, largeQuerySize, shard1Segment1Docs);
473+
assertThat(cache.getStats(shard1).getMemorySizeInBytes(), equalTo(shard1Segment1CacheMemory));
474+
assertThat(cache.getStats(shard2).getMemorySizeInBytes(), equalTo(0L));
475+
for (int i = 0; i < shard2Queries; ++i) {
476+
shard2Searcher.count(new DummyQuery("ingest2-" + i, smallQuerySize));
477+
}
478+
/*
479+
* Now that we have cached some smaller things for shard2, the cache memory for shard1 has gone down. This is expected because we
480+
* report cache memory proportional to the number of segments for each shard, ignoring the number of documents or the actual
481+
* document sizes. Since the shard2 requests were smaller, the average cache memory size per segment has now gone down.
482+
*/
483+
assertThat(cache.getStats(shard1).getMemorySizeInBytes(), lessThan(shard1Segment1CacheMemory));
484+
long shard1CacheBytes = cache.getStats(shard1).getMemorySizeInBytes();
485+
long shard2CacheBytes = cache.getStats(shard2).getMemorySizeInBytes();
486+
long shard2Segment1CacheMemory = calculateActualCacheMemoryForSegment(shard2Queries, smallQuerySize, shard2Segment1Docs);
487+
488+
long totalMemory = shard1Segment1CacheMemory + shard2Segment1CacheMemory;
489+
// Each shard has some fixed overhead that we need to account for:
490+
long shard1Overhead = calculateOverheadForSegment(shard1Queries, shard1Segment1Docs);
491+
long shard2Overhead = calculateOverheadForSegment(shard2Queries, shard2Segment1Docs);
492+
long totalMemoryMinusOverhead = totalMemory - (shard1Overhead + shard2Overhead);
493+
/*
494+
* Note that the expected amount of memory we're calculating is based on the proportion of the number of queries to each shard
495+
* (since each shard currently only has queries to one segment)
496+
*/
497+
double shard1Segment1CacheMemoryShare = ((double) shard1Queries / (shard1Queries + shard2Queries)) * (totalMemoryMinusOverhead)
498+
+ shard1Overhead;
499+
double shard2Segment1CacheMemoryShare = ((double) shard2Queries / (shard1Queries + shard2Queries)) * (totalMemoryMinusOverhead)
500+
+ shard2Overhead;
501+
assertThat((double) shard1CacheBytes, closeTo(shard1Segment1CacheMemoryShare, 1)); // accounting for rounding
502+
assertThat((double) shard2CacheBytes, closeTo(shard2Segment1CacheMemoryShare, 1)); // accounting for rounding
503+
504+
// Now we cache just more "big" searches on shard1, but on a different segment:
505+
for (int i = 0; i < shard1Queries; ++i) {
506+
shard1Segment2Searcher.count(new DummyQuery("ingest3-" + i, largeQuerySize));
507+
}
508+
long shard1Segment2CacheMemory = calculateActualCacheMemoryForSegment(shard1Queries, largeQuerySize, shard1Segment2Docs);
509+
totalMemory = shard1Segment1CacheMemory + shard2Segment1CacheMemory + shard1Segment2CacheMemory;
510+
// Each shard has some fixed overhead that we need to account for:
511+
shard1Overhead = shard1Overhead + calculateOverheadForSegment(shard1Queries, shard1Segment2Docs);
512+
totalMemoryMinusOverhead = totalMemory - (shard1Overhead + shard2Overhead);
513+
/*
514+
* Note that the expected amount of memory we're calculating is based on the proportion of the number of queries to each segment.
515+
* The number of documents and the size of documents is irrelevant (aside from computing the fixed overhead).
516+
*/
517+
shard1Segment1CacheMemoryShare = ((double) (2 * shard1Queries) / ((2 * shard1Queries) + shard2Queries)) * (totalMemoryMinusOverhead)
518+
+ shard1Overhead;
519+
shard1CacheBytes = cache.getStats(shard1).getMemorySizeInBytes();
520+
assertThat((double) shard1CacheBytes, closeTo(shard1Segment1CacheMemoryShare, 1)); // accounting for rounding
521+
522+
// Now make sure the cache only has items for shard2:
523+
for (int i = 0; i < (maxCacheSize * 2); ++i) {
524+
shard2Searcher.count(new DummyQuery("ingest4-" + i, smallQuerySize));
525+
}
526+
assertThat(cache.getStats(shard1).getMemorySizeInBytes(), equalTo(0L));
527+
assertThat(
528+
cache.getStats(shard2).getMemorySizeInBytes(),
529+
equalTo(calculateActualCacheMemoryForSegment(maxCacheSize, smallQuerySize, shard2Segment1Docs))
530+
);
531+
532+
IOUtils.close(closeableList);
533+
cache.onClose(shard1);
534+
cache.onClose(shard2);
535+
cache.close();
536+
}
537+
538+
/*
539+
* This calculates the memory that actually used by a segment in the IndicesQueryCache. It assumes queryCount queries are made to the
540+
* segment, and query is querySize bytes in size. It assumes that the shard contains numDocs documents.
541+
*/
542+
private long calculateActualCacheMemoryForSegment(long queryCount, long querySize, long numDocs) {
543+
return (queryCount * (querySize + 24)) + calculateOverheadForSegment(queryCount, numDocs);
544+
}
545+
546+
/*
547+
* This computes the part of the recorded IndicesQueryCache memory that is assigned to a segment and *not* divided up proportionally
548+
* when the cache reports the memory usage of each shard.
549+
*/
550+
private long calculateOverheadForSegment(long queryCount, long numDocs) {
551+
return queryCount * (112 + (8 * ((numDocs - 1) / 64)));
552+
}
553+
554+
/*
555+
* This returns an IndexSearcher for a single new segment in the given shard.
556+
*/
557+
private IndexSearcher initializeSegment(ShardId shard, int numDocs, List<Closeable> closeableList) throws Exception {
558+
AtomicReference<IndexSearcher> indexSearcherReference = new AtomicReference<>();
559+
/*
560+
* Usually creating an IndexWriter like this results in a single segment getting created, but sometimes it results in more. For the
561+
* sake of keeping the calculations in this test simple we want just a single segment. So we do this in an assertBusy.
562+
*/
563+
assertBusy(() -> {
564+
Directory dir = newDirectory();
565+
IndexWriter indexWriter = new IndexWriter(dir, newIndexWriterConfig());
566+
for (int i = 0; i < numDocs; i++) {
567+
indexWriter.addDocument(new Document());
568+
}
569+
DirectoryReader directoryReader = DirectoryReader.open(indexWriter);
570+
indexWriter.close();
571+
directoryReader = ElasticsearchDirectoryReader.wrap(directoryReader, shard);
572+
IndexSearcher indexSearcher = new IndexSearcher(directoryReader);
573+
indexSearcherReference.set(indexSearcher);
574+
indexSearcher.setQueryCachingPolicy(TrivialQueryCachingPolicy.ALWAYS);
575+
closeableList.add(directoryReader);
576+
closeableList.add(dir);
577+
assertThat(indexSearcher.getLeafContexts().size(), equalTo(1));
578+
});
579+
return indexSearcherReference.get();
580+
}
407581
}

0 commit comments

Comments
 (0)