| // Copyright (C) 2019 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.plugins.checks; |
| |
| import static java.util.concurrent.TimeUnit.NANOSECONDS; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Stopwatch; |
| import com.google.common.cache.CacheLoader; |
| import com.google.common.cache.CacheStats; |
| import com.google.common.cache.LoadingCache; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.common.util.concurrent.AtomicLongMap; |
| import com.google.gerrit.entities.Change; |
| import com.google.gerrit.entities.PatchSet; |
| import com.google.gerrit.entities.Project; |
| import com.google.gerrit.exceptions.StorageException; |
| import com.google.gerrit.extensions.annotations.PluginName; |
| import com.google.gerrit.metrics.Description; |
| import com.google.gerrit.metrics.Description.Units; |
| import com.google.gerrit.metrics.Field; |
| import com.google.gerrit.metrics.MetricMaker; |
| import com.google.gerrit.metrics.Timer1; |
| import com.google.gerrit.plugins.checks.api.CombinedCheckState; |
| import com.google.gerrit.plugins.checks.cache.proto.Cache.CombinedCheckStateCacheKeyProto; |
| import com.google.gerrit.server.cache.CacheModule; |
| import com.google.gerrit.server.cache.serialize.EnumCacheSerializer; |
| import com.google.gerrit.server.cache.serialize.ProtobufSerializer; |
| import com.google.gerrit.server.logging.PluginMetadata; |
| import com.google.inject.Inject; |
| import com.google.inject.Module; |
| import com.google.inject.Singleton; |
| import com.google.inject.name.Named; |
| import java.io.IOException; |
| import java.time.Duration; |
| import java.util.concurrent.ExecutionException; |
| |
| /** |
| * Cache of {@link CombinedCheckState} per change. |
| * |
| * <p>In the absence of plugin-defined index fields, this cache is used to performantly populate the |
| * {@code combinedState} field in {@code ChangeCheckInfo} in the query path. |
| */ |
| @Singleton |
| public class CombinedCheckStateCache { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private static final String NAME = "combined_check_state"; |
| |
| public static Module module() { |
| return new CacheModule() { |
| @Override |
| public void configure() { |
| persist(NAME, CombinedCheckStateCacheKeyProto.class, CombinedCheckState.class) |
| .version(1) |
| .maximumWeight(10000) |
| .diskLimit(-1) |
| .keySerializer(new ProtobufSerializer<>(CombinedCheckStateCacheKeyProto.parser())) |
| .valueSerializer(new EnumCacheSerializer<>(CombinedCheckState.class)) |
| .loader(Loader.class); |
| } |
| }; |
| } |
| |
| @Singleton |
| static class Metrics { |
| // Pair of metric and manual counters, to work around the fact that metric classes have no |
| // getters. |
| private final Timer1<Boolean> reloadLatency; |
| private final AtomicLongMap<Boolean> reloadCount; |
| |
| @Inject |
| Metrics(@PluginName String pluginName, MetricMaker metricMaker) { |
| reloadLatency = |
| metricMaker.newTimer( |
| "checks/reload_combined_check_state", |
| new Description("Latency for reloading combined check state") |
| .setCumulative() |
| .setUnit(Units.MILLISECONDS), |
| Field.ofBoolean( |
| "updated", |
| (metadataBuilder, fieldValue) -> |
| metadataBuilder |
| .pluginName(pluginName) |
| .addPluginMetadata( |
| PluginMetadata.create("updated", Boolean.toString(fieldValue)))) |
| .description("whether reloading resulted in updating the cached value") |
| .build()); |
| reloadCount = AtomicLongMap.create(); |
| } |
| |
| void recordReload(boolean updated, Duration elapsed) { |
| reloadLatency.record(updated, elapsed.toNanos(), NANOSECONDS); |
| reloadCount.incrementAndGet(updated); |
| } |
| |
| long getReloadCount(boolean updated) { |
| return reloadCount.get(updated); |
| } |
| } |
| |
| private final LoadingCache<CombinedCheckStateCacheKeyProto, CombinedCheckState> cache; |
| private final Loader loader; |
| private final Metrics metrics; |
| |
| @Inject |
| CombinedCheckStateCache( |
| @Named(NAME) LoadingCache<CombinedCheckStateCacheKeyProto, CombinedCheckState> cache, |
| Loader loader, |
| Metrics metrics) { |
| this.cache = cache; |
| this.loader = loader; |
| this.metrics = metrics; |
| } |
| |
| /** |
| * Get the state from the cache, computing it from checks ref if necessary. |
| * |
| * @param project project containing the change. |
| * @param psId patch set to which the state corresponds. |
| * @return combined check state. |
| */ |
| public CombinedCheckState get(Project.NameKey project, PatchSet.Id psId) { |
| try { |
| return cache.get(key(project, psId)); |
| } catch (ExecutionException e) { |
| throw new StorageException(e); |
| } |
| } |
| |
| /** |
| * Load the state from primary storage, and update the state in the cache only if it changed. |
| * |
| * <p>This method does a cache lookup followed by a write, which is inherently racy. It is |
| * intended to be used whenever we need to recompute the combined check state, for example when |
| * sending a {@code ChangeCheckInfo} to the client. As a result, inconsistencies between the cache |
| * and the actual state should tend to get fixed up immediately after a user views the change. |
| * |
| * @param project project containing the change. |
| * @param psId patch set to which the state corresponds. |
| * @return combined check state. |
| */ |
| public CombinedCheckState reload(Project.NameKey project, PatchSet.Id psId) { |
| // Possible future optimization: short-circuit before calling this method, if an individual |
| // check transitioned between two CheckStates which would result in the same CombinedCheckState. |
| Stopwatch sw = Stopwatch.createStarted(); |
| // Arbitrarily assume that the cache was updated unless we can conclusively prove it wasn't. |
| boolean updated = true; |
| try { |
| CombinedCheckStateCacheKeyProto key = key(project, psId); |
| CombinedCheckState newState = loader.load(key); |
| CombinedCheckState oldState = cache.getIfPresent(key); |
| if (newState != oldState) { |
| cache.put(key, newState); |
| } else { |
| updated = false; |
| } |
| return newState; |
| } finally { |
| metrics.recordReload(updated, sw.elapsed()); |
| } |
| } |
| |
| /** |
| * Update the state in the cache only if it changed. |
| * |
| * <p>This method returns no value and should be called when the caller may have recently updated |
| * the value in primary storage, but does not need the actual value. There is no guarantee that an |
| * update initiated by this method completes synchronously. |
| * |
| * <p>This method does a cache lookup followed by a write, which is inherently racy. |
| * Inconsistencies between the cache and the actual state should tend to get fixed up immediately |
| * after a user views the change, since the read path calls {@link #reload(Project.NameKey, |
| * PatchSet.Id)}. |
| * |
| * @param project project containing the change. |
| * @param psId patch set to which the state corresponds. |
| */ |
| public void updateIfNecessary(Project.NameKey project, PatchSet.Id psId) { |
| // Possible future optimization: make this whole method async, in the FanOutExecutor. |
| try { |
| reload(project, psId); |
| } catch (RuntimeException e) { |
| logger.atWarning().withCause(e).log( |
| "failed to reload CombinedCheckState for %s in %s", psId, project); |
| } |
| } |
| |
| /** |
| * Directly put a state into the cache. |
| * |
| * @param project project containing the change. |
| * @param psId patch set to which the state corresponds. |
| * @param state combined check state. |
| */ |
| @VisibleForTesting |
| public void putForTest(Project.NameKey project, PatchSet.Id psId, CombinedCheckState state) { |
| cache.put(key(project, psId), state); |
| } |
| |
| @VisibleForTesting |
| public long getReloadCount(boolean updated) { |
| return metrics.getReloadCount(updated); |
| } |
| |
| @VisibleForTesting |
| public CacheStats getStats() { |
| return cache.stats(); |
| } |
| |
| private static CombinedCheckStateCacheKeyProto key(Project.NameKey project, PatchSet.Id psId) { |
| return CombinedCheckStateCacheKeyProto.newBuilder() |
| .setProject(project.get()) |
| .setChangeId(psId.changeId().get()) |
| .setPatchSetId(psId.get()) |
| .build(); |
| } |
| |
| @Singleton |
| private static class Loader |
| extends CacheLoader<CombinedCheckStateCacheKeyProto, CombinedCheckState> { |
| private final Checks checks; |
| |
| @Inject |
| Loader(Checks checks) { |
| this.checks = checks; |
| } |
| |
| @Override |
| public CombinedCheckState load(CombinedCheckStateCacheKeyProto key) { |
| try { |
| return checks.getCombinedCheckState( |
| Project.nameKey(key.getProject()), |
| PatchSet.id(Change.id(key.getChangeId()), key.getPatchSetId())); |
| } catch (IOException e) { |
| throw new StorageException(e); |
| } |
| } |
| } |
| } |