Skip to content

Consider properly weighing cache entries #1852

Description

@fhennig

Problem

siloQueryCache is bounded by entry count, not memory:

spring.cache.caffeine.spec=maximumSize=50000,softValues

One entry can be a List of millions of AggregationData, each holding a
Map<String, JsonNode> with hundreds of entries, so 50 000 entries can be many GB.
softValues is the only backstop, and it only clears entries once the JVM is already
near OOM - too late under a burst, and soft references add GC pressure. This contributed
to a heap exhaustion / GC-thrash outage on 2026-09-01.

Proposal

Bound the cache by the estimated retained heap size of its entries instead of by entry
count:

  • Configure Caffeine programmatically (a Caffeine bean, which Spring Boot uses in
    preference to spring.cache.caffeine.spec) with maximumWeight + a Weigher.
    maximumWeight and maximumSize are mutually exclusive, so this replaces
    maximumSize; drop softValues as well.

  • A weigher that estimates bytes per cached result - per row, plus per group-by column
    for aggregations, since an aggregation row is a Map whose cost scales with the number
    of columns. Rough sketch:

    private const val ROW_OVERHEAD_BYTES = 64L
    private const val AGGREGATION_FIELD_BYTES = 120L
    private const val NON_AGGREGATION_ROW_BYTES = 200L
    
    class SiloQueryCacheWeigher : Weigher<Any, Any> {
        override fun weigh(key: Any, value: Any): Int {
            val rows = (value as? WithDataVersion<*>)?.queryResult as? List<*> ?: return 1
            var weight = 0L
            for (row in rows) {
                weight += when (row) {
                    is AggregationData -> ROW_OVERHEAD_BYTES + row.fields.size * AGGREGATION_FIELD_BYTES
                    else -> NON_AGGREGATION_ROW_BYTES
                }
                if (weight >= Int.MAX_VALUE) return Int.MAX_VALUE
            }
            return weight.toInt().coerceAtLeast(1)
        }
    }
  • Default the limit to a fraction of the max heap (Runtime.maxMemory(), effectively
    -Xmx) so it scales with the instance, e.g. 0.25. Allow overrides via
    lapis.cache.siloQueryMaxWeightHeapFraction and an absolute
    lapis.cache.siloQueryMaxWeightBytes (taking precedence when set).

  • Enable recordStats() so hit rate and evictions show up in metrics (currently only
    cache.size is exposed - the cache logs a warning that it records no statistics).

Blocking (sort of)

We need to have sound values for row overhead and cell value bytes, otherwise the weigher will be off and we can still run into OOM issues. At the time of writing I didn't feel confident in calculating or guessing these values, and it would probably be best to measure this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions