Repository: squashql/squashql
Pull request #337 (2024-05-13 13:31:41)

Add cache statistics


Patch:
diff --git a/core/src/main/java/io/squashql/query/cache/CaffeineQueryCache.java b/core/src/main/java/io/squashql/query/cache/CaffeineQueryCache.java
index cc24cdb..ac639c9 100644
--- a/core/src/main/java/io/squashql/query/cache/CaffeineQueryCache.java
+++ b/core/src/main/java/io/squashql/query/cache/CaffeineQueryCache.java
@@ -18,10 +18,13 @@ import io.squashql.table.ColumnarTable;
 import io.squashql.table.Table;
 import io.squashql.type.TypedField;
 import lombok.AllArgsConstructor;
+import org.eclipse.collections.api.list.primitive.MutableIntList;
+import org.eclipse.collections.impl.list.mutable.primitive.IntArrayList;
 
 import java.time.Duration;
 import java.util.*;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReadWriteLock;
 
@@ -31,6 +34,7 @@ public class CaffeineQueryCache implements QueryCache {
 
   public static final int MAX_SIZE;
   public static final int EXPIRATION_DURATION; // in minutes
+  protected static final int[] histogram = new int[]{0, 100, 1000, 10_000, 50_000, 100_000, 200_000, 500_000, 1_000_000};
 
   static {
     String size = System.getProperty("io.squashql.cache.size", Integer.toString(32));
@@ -94,7 +98,11 @@ public class CaffeineQueryCache implements QueryCache {
   public void contributeToCache(Table result, Set<CompiledMeasure> measures, QueryCacheKey scope) {
     Table cache = this.results.get(scope, s -> {
       this.measureCounter.recordMisses(measures.size());
-      return new DelegateTable(((ColumnarTable) result).copy());
+      if (result instanceof ColumnarTable ct) {
+        return new DelegateTable(ct.copy());
+      } else {
+        return new DelegateTable(result);
+      }
     });
 
     executeWrite(cache, () -> {
@@ -233,4 +241,59 @@ public class CaffeineQueryCache implements QueryCache {
       return this.underlying.count();
     }
   }
+
+  protected int[] getHistogramInteger() {
+    ConcurrentMap<QueryCacheKey, DelegateTable> map = this.results.asMap();
+    MutableIntList l = new IntArrayList();
+    for (DelegateTable value : map.values()) {
+      int nbCells = executeRead(value, () -> value.count() * value.headers().size());
+      l.add(nbCells);
+    }
+    return getCountByHist(l, histogram);
+  }
+
+  @Override
+  public String getHistogram() {
+    return getHistogramHumanRepresentation(getHistogramInteger());
+  }
+
+  protected static String getHistogramHumanRepresentation(int[] counts) {
+    StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < histogram.length; i++) {
+      sb
+              .append("[")
+              .append(histogram[i] + (i + 1 < histogram.length ? 1 : 0));
+      if (i + 1 < histogram.length) {
+        sb
+                .append("-")
+                .append(histogram[i + 1]);
+      } else {
+        sb.append("<");
+      }
+      sb
+              .append(":")
+              .append(counts[i])
+              .append("]");
+      if (i < histogram.length - 1) {
+        sb.append(",");
+      }
+    }
+    return sb.toString();
+  }
+
+  public static int[] getCountByHist(MutableIntList l, int[] hist) {
+    int[] counts = new int[hist.length];
+    l.forEach(value -> {
+      int i = 0;
+      for (int j = 0; j < hist.length; j++) {
+        if (value > hist[j]) {
+          i = j;
+        } else {
+          break;
+        }
+      }
+      counts[i]++;
+    });
+    return counts;
+  }
 }
diff --git a/core/src/main/java/io/squashql/query/cache/EmptyQueryCache.java b/core/src/main/java/io/squashql/query/cache/EmptyQueryCache.java
index 85bcf05..8f42e8c 100644
--- a/core/src/main/java/io/squashql/query/cache/EmptyQueryCache.java
+++ b/core/src/main/java/io/squashql/query/cache/EmptyQueryCache.java
@@ -47,4 +47,9 @@ public class EmptyQueryCache implements QueryCache {
   public CacheStatsDto stats(SquashQLUser user) {
     return new CacheStatsDto(-1, -1, -1);
   }
+
+  @Override
+  public String getHistogram() {
+    return "";
+  }
 }
diff --git a/core/src/main/java/io/squashql/query/cache/GlobalCache.java b/core/src/main/java/io/squashql/query/cache/GlobalCache.java
index bd6a2b1..5ac6b4e 100644
--- a/core/src/main/java/io/squashql/query/cache/GlobalCache.java
+++ b/core/src/main/java/io/squashql/query/cache/GlobalCache.java
@@ -6,6 +6,8 @@ import io.squashql.query.dto.CacheStatsDto;
 import io.squashql.table.ColumnarTable;
 import io.squashql.table.Table;
 
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
@@ -24,7 +26,7 @@ public class GlobalCache implements QueryCache {
     this.cacheSupplier = cacheSupplier;
   }
 
-  private static SquashQLUser user(SquashQLUser user) {
+  protected static SquashQLUser user(SquashQLUser user) {
     return user == null ? ANONYMOUS : user;
   }
 
@@ -66,4 +68,17 @@ public class GlobalCache implements QueryCache {
   public CacheStatsDto stats(SquashQLUser user) {
     return this.cacheByUser.computeIfAbsent(user(user), u -> this.cacheSupplier.get()).stats();
   }
+
+  @Override
+  public String getHistogram() {
+    List<int[]> histograms = new ArrayList<>();
+    this.cacheByUser.forEach((user, cache) -> histograms.add(cache.getHistogramInteger()));
+
+    int[] counts = new int[CaffeineQueryCache.histogram.length];
+    for (int i = 0; i < CaffeineQueryCache.histogram.length; i++) {
+      int j = i;
+      histograms.forEach(h -> counts[j] += h[j]);
+    }
+    return CaffeineQueryCache.getHistogramHumanRepresentation(counts);
+  }
 }
diff --git a/core/src/main/java/io/squashql/query/cache/QueryCache.java b/core/src/main/java/io/squashql/query/cache/QueryCache.java
index cd5cf92..6a8dcb9 100644
--- a/core/src/main/java/io/squashql/query/cache/QueryCache.java
+++ b/core/src/main/java/io/squashql/query/cache/QueryCache.java
@@ -33,6 +33,8 @@ public interface QueryCache {
 
   CacheStatsDto stats(SquashQLUser user);
 
+  String getHistogram();
+
   record QueryCacheKey(QueryScope scope, SquashQLUser user) {
   }
 }
diff --git a/core/src/main/java/io/squashql/util/SchemaTypeScriptCodeGenerator.java b/core/src/main/java/io/squashql/util/SchemaTypeScriptCodeGenerator.java
deleted file mode 100644
index 2f5a63f..0000000
--- a/core/src/main/java/io/squashql/util/SchemaTypeScriptCodeGenerator.java
+++ /dev/null
@@ -1,66 +0,0 @@
-package io.squashql.util;
-
-import io.squashql.store.Datastore;
-import io.squashql.store.Store;
-import io.squashql.type.TableTypedField;
-
-import java.util.Map;
-import java.util.TreeMap;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class SchemaTypeScriptCodeGenerator {
-
-  public static String transformName(String fieldName, boolean shouldStartWithUpperCase) {
-    Pattern compile = Pattern.compile("[^A-Za-z0-9]");
-    Matcher matcher = compile.matcher(fieldName);
-    int start, end = -1;
-    StringBuilder sb = new StringBuilder();
-    while (matcher.find()) {
-      start = end + 1;
-      end = matcher.start();
-      if (start != end) {
-        sb.append(fieldName.substring(start, start + 1).toUpperCase());
-        sb.append(fieldName, start + 1, end);
-      } else {
-        sb.append(fieldName, start, end);
-      }
-    }
-
-    start = end + 1;
-    end = fieldName.length();
-
-    if (start != end) {
-      sb.append(fieldName.substring(start, start + 1).toUpperCase());
-      sb.append(fieldName, start + 1, end);
-    } else {
-      sb.append(fieldName, start, end);
-    }
-
-    return (shouldStartWithUpperCase ? sb.substring(0, 1).toUpperCase() : sb.substring(0, 1).toLowerCase()) + sb.substring(1);
-  }
-
-  public static String getFileContent(Datastore datastore) {
-    StringBuilder sb = new StringBuilder();
-    Map<String, Store> storesByName = new TreeMap<>(datastore.storeByName()); // order by store name
-    storesByName.forEach((storeName, store) -> {
-      sb.append("export class ").append(transformName(storeName, true)).append("Table {").append(System.lineSeparator());
-      addAttribute(sb, "tableName", storeName);
-      for (TableTypedField field : store.fields()) {
-        addAttribute(sb, transformName(field.name(), false), field.name());
-      }
-      sb.append('}').append(System.lineSeparator());
-    });
-    return sb.toString();
-  }
-
-  private static void addAttribute(StringBuilder sb, String attributeName, String attributeValue) {
-    sb.append("\t readonly ")
-            .append(attributeName)
-            .append(": string = ")
-            .append('"')
-            .append(attributeValue)
-            .append('"')
-            .append(System.lineSeparator());
-  }
-}
diff --git a/documentation/CACHE.md b/documentation/CACHE.md
index 96fd470..b06cbec 100644
--- a/documentation/CACHE.md
+++ b/documentation/CACHE.md
@@ -31,6 +31,23 @@ public class MyComponent {
 
 The method `void clear()` or `void clear(SquashQLUser user)` are to be called to invalidate the cache.
 
+### Statistics
+
+The current implementation provides basic statistics to monitor cache usage. 
+
+```
+cache.getHistogram()
+```
+
+returns a histogram of number of tables per table size ranges. Size of table = number of rows * number of columns. 
+For instance:
+
+```
+[1-100:2],[101-1000:1],[1001-10000:3],[10001-50000:1],[50001-100000:0],[100001-200000:0],[200001-500000:0],[500001-1000000:0],[1000000<:1]
+```
+
+2 table have a size between 1 and 100, 1 table has a size between 101 and 1000, 3 tables have a size between 1001 and 10000...
+
 ### More details
 
 The code can be found in the classes `GlobalCache` and `CaffeineQueryCache`. See usage in `QueryExecutor`.


Test patch:
diff --git a/core/src/test/java/io/squashql/query/cache/TestCaffeineCache.java b/core/src/test/java/io/squashql/query/cache/TestCaffeineCache.java
new file mode 100644
index 0000000..c8b4d2d
--- /dev/null
+++ b/core/src/test/java/io/squashql/query/cache/TestCaffeineCache.java
@@ -0,0 +1,112 @@
+package io.squashql.query.cache;
+
+import io.squashql.query.BasicUser;
+import io.squashql.query.Header;
+import io.squashql.query.SquashQLUser;
+import io.squashql.query.compiled.CompiledMeasure;
+import io.squashql.query.compiled.MaterializedTable;
+import io.squashql.query.database.QueryScope;
+import io.squashql.query.dictionary.ObjectArrayDictionary;
+import io.squashql.table.Table;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.IntSupplier;
+import java.util.stream.IntStream;
+
+public class TestCaffeineCache {
+
+  private static final IntSupplier s = new AtomicInteger()::getAndIncrement;
+
+  @Test
+  void testHistogramCaffeineQueryCache() {
+    CaffeineQueryCache cache = new CaffeineQueryCache();
+    cache.contributeToCache(new FakeTable(1, 2), Set.of(), newKey());
+    cache.contributeToCache(new FakeTable(10, 2), Set.of(), newKey());
+    cache.contributeToCache(new FakeTable(100, 20), Set.of(), newKey());
+    cache.contributeToCache(new FakeTable(200, 1), Set.of(), newKey());
+    cache.contributeToCache(new FakeTable(100, 20), Set.of(), newKey());
+    cache.contributeToCache(new FakeTable(100, 20), Set.of(), newKey());
+    cache.contributeToCache(new FakeTable(100, 200), Set.of(), newKey());
+    cache.contributeToCache(new FakeTable(1000, 2000), Set.of(), newKey());
+
+    String histogram = cache.getHistogram();
+    Assertions.assertThat(histogram).isEqualTo("[1-100:2],[101-1000:1],[1001-10000:3],[10001-50000:1],[50001-100000:0],[100001-200000:0],[200001-500000:0],[500001-1000000:0],[1000000<:1]");
+  }
+
+  @Test
+  void testHistogramGlobalCache() {
+    GlobalCache cache = new GlobalCache(CaffeineQueryCache::new);
+
+    BasicUser paul = new BasicUser("paul");
+    BasicUser peter = new BasicUser("peter");
+    cache.contributeToCache(new FakeTable(1, 2), Set.of(), newKey(paul)); // size 2
+    cache.contributeToCache(new FakeTable(10, 2), Set.of(), newKey(peter)); // 20
+    cache.contributeToCache(new FakeTable(100, 20), Set.of(), newKey(paul)); // 2000
+    cache.contributeToCache(new FakeTable(200, 1), Set.of(), newKey(paul)); // 200
+    cache.contributeToCache(new FakeTable(100, 20), Set.of(), newKey(peter)); // 2000
+
+    String histogram = cache.getHistogram();
+    Assertions.assertThat(histogram).isEqualTo("[1-100:2],[101-1000:1],[1001-10000:2],[10001-50000:0],[50001-100000:0],[100001-200000:0],[200001-500000:0],[500001-1000000:0],[1000000<:0]");
+  }
+
+  private static QueryCache.QueryCacheKey newKey() {
+    return newKey(GlobalCache.user(null));
+  }
+
+  private static QueryCache.QueryCacheKey newKey(SquashQLUser user) {
+    return new QueryCache.QueryCacheKey(
+            new QueryScope(new MaterializedTable("table-" + s.getAsInt(), List.of()), List.of(), null, null, List.of(), Set.of(), List.of(), List.of(), 10),
+            user);
+  }
+
+  private static class FakeTable implements Table {
+
+    private final int count;
+    private final List<Header> headers;
+
+    private FakeTable(int count, int nbOfHeaders) {
+      this.count = count;
+      this.headers = IntStream.range(0, nbOfHeaders).mapToObj(i -> new Header("name-" + i, String.class, false)).toList();
+    }
+
+    @Override
+    public ObjectArrayDictionary pointDictionary() {
+      return null;
+    }
+
+    @Override
+    public List<Header> headers() {
+      return this.headers;
+    }
+
+    @Override
+    public Set<CompiledMeasure> measures() {
+      return Set.of();
+    }
+
+    @Override
+    public void addAggregates(Header header, CompiledMeasure measure, List<Object> values) {
+
+    }
+
+    @Override
+    public void transferAggregates(Table from, CompiledMeasure measure) {
+
+    }
+
+    @Override
+    public int count() {
+      return this.count;
+    }
+
+    @Override
+    public Iterator<List<Object>> iterator() {
+      return null;
+    }
+  }
+}
diff --git a/core/src/test/java/io/squashql/util/TestSchemaTypeScriptCodeGenerator.java b/core/src/test/java/io/squashql/util/TestSchemaTypeScriptCodeGenerator.java
deleted file mode 100644
index 8dca702..0000000
--- a/core/src/test/java/io/squashql/util/TestSchemaTypeScriptCodeGenerator.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package io.squashql.util;
-
-import org.assertj.core.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-public class TestSchemaTypeScriptCodeGenerator {
-
-  @Test
-  void testTransformName() {
-    String expected = "helloWorldAbc";
-    String expectedUpperCase = "HelloWorldAbc";
-    Assertions.assertThat(SchemaTypeScriptCodeGenerator.transformName("hello.world_abc", false)).isEqualTo(expected);
-    Assertions.assertThat(SchemaTypeScriptCodeGenerator.transformName("hello.world_abc", true)).isEqualTo(expectedUpperCase);
-    Assertions.assertThat(SchemaTypeScriptCodeGenerator.transformName("helloWorldAbc", false)).isEqualTo(expected);
-    Assertions.assertThat(SchemaTypeScriptCodeGenerator.transformName("helloWorldAbc", true)).isEqualTo(expectedUpperCase);
-    Assertions.assertThat(SchemaTypeScriptCodeGenerator.transformName(".hello.WorldAbc", false)).isEqualTo(expected);
-    Assertions.assertThat(SchemaTypeScriptCodeGenerator.transformName(".hello.WorldAbc", true)).isEqualTo(expectedUpperCase);
-    Assertions.assertThat(SchemaTypeScriptCodeGenerator.transformName("helloWorldAbc.", false)).isEqualTo(expected);
-    Assertions.assertThat(SchemaTypeScriptCodeGenerator.transformName("helloWorldAbc.", true)).isEqualTo(expectedUpperCase);
-  }
-}