DocumentTemplateMaterializationCache.java

package com.taxonomy.templates;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;

/**
 * Bounded, commit-keyed cache for deterministic DOTX projections and validation results.
 *
 * <p>A Git commit is immutable, so changing a template naturally changes the key. The
 * synchronized single-flight boundary also prevents parallel WebDAV, health and export
 * requests from repeatedly packing the same large OOXML tree.</p>
 */
@Component
public final class DocumentTemplateMaterializationCache {

    static final int DEFAULT_MAX_ENTRIES = 64;
    static final long DEFAULT_MAX_BYTES = 100L * 1024 * 1024;
    private static final String BUILDER_VERSION = "dotx-v1";

    private final int maxEntries;
    private final long maxBytes;
    private final LinkedHashMap<Key, byte[]> packed =
            new LinkedHashMap<>(16, 0.75f, true);
    private final LinkedHashMap<Key, Boolean> validated =
            new LinkedHashMap<>(16, 0.75f, true);
    private long currentBytes;

    private final Counter packHits;
    private final Counter packMisses;
    private final Counter validationHits;
    private final Counter validationMisses;
    private final Counter evictions;
    private final Timer packTimer;

    public DocumentTemplateMaterializationCache(
            ObjectProvider<MeterRegistry> registries) {
        this(DEFAULT_MAX_ENTRIES, DEFAULT_MAX_BYTES,
                registries == null ? null : registries.getIfAvailable());
    }

    DocumentTemplateMaterializationCache() {
        this(DEFAULT_MAX_ENTRIES, DEFAULT_MAX_BYTES, null);
    }

    DocumentTemplateMaterializationCache(
            int maxEntries,
            long maxBytes,
            MeterRegistry registry) {
        if (maxEntries < 1 || maxBytes < 1) {
            throw new IllegalArgumentException("Template cache limits must be positive");
        }
        this.maxEntries = maxEntries;
        this.maxBytes = maxBytes;
        packHits = counter(registry, "taxonomy.document.template.cache.pack.hits");
        packMisses = counter(registry, "taxonomy.document.template.cache.pack.misses");
        validationHits = counter(registry,
                "taxonomy.document.template.cache.validation.hits");
        validationMisses = counter(registry,
                "taxonomy.document.template.cache.validation.misses");
        evictions = counter(registry, "taxonomy.document.template.cache.evictions");
        packTimer = registry == null ? null : Timer.builder(
                "taxonomy.document.template.materialization")
                .description("Time spent producing deterministic DOTX bytes")
                .register(registry);
        if (registry != null) {
            registry.gauge("taxonomy.document.template.cache.entries", packed,
                    Map::size);
            registry.gauge("taxonomy.document.template.cache.bytes", this,
                    cache -> cache.sizeBytes());
        }
    }

    public synchronized byte[] packed(
            String templateId,
            String commitId,
            IoSupplier<byte[]> materializer) throws IOException {
        Key key = key(templateId, commitId);
        byte[] present = packed.get(key);
        if (present != null) {
            increment(packHits);
            return present.clone();
        }
        increment(packMisses);
        byte[] produced;
        if (packTimer == null) {
            produced = materializer.get();
        } else {
            try {
                produced = packTimer.recordCallable(materializer::get);
            } catch (IOException exception) {
                throw exception;
            } catch (Exception exception) {
                throw new IOException("Could not materialize DOTX template", exception);
            }
        }
        Objects.requireNonNull(produced, "materialized DOTX");
        if (produced.length <= maxBytes) {
            packed.put(key, produced.clone());
            currentBytes += produced.length;
            trim();
        }
        return produced.clone();
    }

    public synchronized void validateOnce(
            String templateId,
            String commitId,
            CheckedRunnable validation) throws IOException {
        Key key = key(templateId, commitId);
        if (Boolean.TRUE.equals(validated.get(key))) {
            increment(validationHits);
            return;
        }
        increment(validationMisses);
        validation.run();
        validated.put(key, Boolean.TRUE);
        while (validated.size() > maxEntries) {
            Key eldest = validated.keySet().iterator().next();
            validated.remove(eldest);
        }
    }

    synchronized int entries() {
        return packed.size();
    }

    synchronized long sizeBytes() {
        return currentBytes;
    }

    private void trim() {
        while (packed.size() > maxEntries || currentBytes > maxBytes) {
            Map.Entry<Key, byte[]> eldest = packed.entrySet().iterator().next();
            packed.remove(eldest.getKey());
            validated.remove(eldest.getKey());
            currentBytes -= eldest.getValue().length;
            increment(evictions);
        }
    }

    private static Key key(String templateId, String commitId) {
        return new Key(
                Objects.requireNonNull(templateId, "templateId"),
                Objects.requireNonNull(commitId, "commitId"),
                BUILDER_VERSION);
    }

    private static Counter counter(MeterRegistry registry, String name) {
        return registry == null ? null : Counter.builder(name).register(registry);
    }

    private static void increment(Counter counter) {
        if (counter != null) {
            counter.increment();
        }
    }

    private record Key(String templateId, String commitId, String builderVersion) {
    }

    @FunctionalInterface
    public interface IoSupplier<T> {
        T get() throws IOException;
    }

    @FunctionalInterface
    public interface CheckedRunnable {
        void run() throws IOException;
    }
}