| // Copyright (C) 2020 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.server.comment; | |
| import static java.nio.charset.StandardCharsets.UTF_8; | |
| import com.google.common.annotations.VisibleForTesting; | |
| import com.google.common.cache.CacheLoader; | |
| import com.google.common.cache.LoadingCache; | |
| import com.google.common.cache.Weigher; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.collect.ImmutableMap; | |
| import com.google.common.collect.Iterables; | |
| import com.google.common.collect.Streams; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.common.hash.Hashing; | |
| import com.google.gerrit.entities.Change; | |
| import com.google.gerrit.entities.Comment; | |
| import com.google.gerrit.entities.CommentContext; | |
| import com.google.gerrit.entities.HumanComment; | |
| import com.google.gerrit.entities.Project; | |
| import com.google.gerrit.exceptions.StorageException; | |
| import com.google.gerrit.proto.Protos; | |
| import com.google.gerrit.server.CommentsUtil; | |
| import com.google.gerrit.server.DraftCommentsReader; | |
| import com.google.gerrit.server.cache.CacheModule; | |
| import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto; | |
| import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto; | |
| import com.google.gerrit.server.cache.serialize.CacheSerializer; | |
| import com.google.gerrit.server.comment.CommentContextLoader.ContextInput; | |
| import com.google.gerrit.server.notedb.ChangeNotes; | |
| import com.google.inject.Inject; | |
| import com.google.inject.Module; | |
| import com.google.inject.name.Named; | |
| import java.io.IOException; | |
| import java.util.HashMap; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.concurrent.ExecutionException; | |
| import java.util.function.Function; | |
| import java.util.stream.Collectors; | |
| /** Implementation of {@link CommentContextCache}. */ | |
| public class CommentContextCacheImpl implements CommentContextCache { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| private static final String CACHE_NAME = "comment_context"; | |
| /** | |
| * Comment context is expected to contain just few lines of code to be displayed beside the | |
| * comment. Setting an upper bound of 100 for padding. | |
| */ | |
| @VisibleForTesting public static final int MAX_CONTEXT_PADDING = 50; | |
| public static Module module() { | |
| return new CacheModule() { | |
| @Override | |
| protected void configure() { | |
| persist(CACHE_NAME, CommentContextKey.class, CommentContext.class) | |
| .version(5) | |
| .diskLimit(1 << 30) // limit the total cache size to 1 GB | |
| .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB | |
| .weigher(CommentContextWeigher.class) | |
| .keySerializer(CommentContextKey.Serializer.INSTANCE) | |
| .valueSerializer(CommentContextSerializer.INSTANCE) | |
| .loader(Loader.class); | |
| bind(CommentContextCache.class).to(CommentContextCacheImpl.class); | |
| } | |
| }; | |
| } | |
| private final LoadingCache<CommentContextKey, CommentContext> contextCache; | |
| @Inject | |
| CommentContextCacheImpl( | |
| @Named(CACHE_NAME) LoadingCache<CommentContextKey, CommentContext> contextCache) { | |
| this.contextCache = contextCache; | |
| } | |
| @Override | |
| public CommentContext get(CommentContextKey comment) { | |
| return getAll(ImmutableList.of(comment)).get(comment); | |
| } | |
| @Override | |
| public ImmutableMap<CommentContextKey, CommentContext> getAll( | |
| Iterable<CommentContextKey> inputKeys) { | |
| ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder(); | |
| // We do two transformations to the input keys: first we adjust the max context padding, and | |
| // second we hash the file path. The transformed keys are used to request context from the | |
| // cache. Keeping a map of the original inputKeys to the transformed keys | |
| Map<CommentContextKey, CommentContextKey> inputKeysToCacheKeys = | |
| Streams.stream(inputKeys) | |
| .collect( | |
| Collectors.toMap( | |
| Function.identity(), | |
| k -> | |
| adjustMaxContextPadding(k) | |
| .toBuilder() | |
| .path(Loader.hashPath(k.path())) | |
| .build())); | |
| try { | |
| ImmutableMap<CommentContextKey, CommentContext> allContext = | |
| contextCache.getAll(inputKeysToCacheKeys.values()); | |
| for (CommentContextKey inputKey : inputKeys) { | |
| CommentContextKey cacheKey = inputKeysToCacheKeys.get(inputKey); | |
| result.put(inputKey, allContext.get(cacheKey)); | |
| } | |
| return result.build(); | |
| } catch (ExecutionException e) { | |
| throw new StorageException("Failed to retrieve comments' context", e); | |
| } | |
| } | |
| private static CommentContextKey adjustMaxContextPadding(CommentContextKey key) { | |
| if (key.contextPadding() < 0) { | |
| logger.atWarning().log( | |
| "Cannot set context padding to a negative number %d. Adjusting the number to 0", | |
| key.contextPadding()); | |
| return key.toBuilder().contextPadding(0).build(); | |
| } | |
| if (key.contextPadding() > MAX_CONTEXT_PADDING) { | |
| logger.atWarning().log( | |
| "Number of requested context lines is %d and exceeding the configured maximum of %d." | |
| + " Adjusting the number to the maximum.", | |
| key.contextPadding(), MAX_CONTEXT_PADDING); | |
| return key.toBuilder().contextPadding(MAX_CONTEXT_PADDING).build(); | |
| } | |
| return key; | |
| } | |
| public enum CommentContextSerializer implements CacheSerializer<CommentContext> { | |
| INSTANCE; | |
| @Override | |
| public byte[] serialize(CommentContext commentContext) { | |
| AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder(); | |
| allBuilder.setContentType(commentContext.contentType()); | |
| commentContext | |
| .lines() | |
| .entrySet() | |
| .forEach( | |
| c -> | |
| allBuilder.addContext( | |
| CommentContextProto.newBuilder() | |
| .setLineNumber(c.getKey()) | |
| .setContextLine(c.getValue()))); | |
| return Protos.toByteArray(allBuilder.build()); | |
| } | |
| @Override | |
| public CommentContext deserialize(byte[] in) { | |
| ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder(); | |
| AllCommentContextProto proto = Protos.parseUnchecked(AllCommentContextProto.parser(), in); | |
| proto.getContextList().stream() | |
| .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine())); | |
| return CommentContext.create(contextLinesMap.build(), proto.getContentType()); | |
| } | |
| } | |
| static class Loader extends CacheLoader<CommentContextKey, CommentContext> { | |
| private final ChangeNotes.Factory notesFactory; | |
| private final CommentsUtil commentsUtil; | |
| private final CommentContextLoader.Factory factory; | |
| private final DraftCommentsReader draftCommentsReader; | |
| @Inject | |
| Loader( | |
| CommentsUtil commentsUtil, | |
| ChangeNotes.Factory notesFactory, | |
| CommentContextLoader.Factory factory, | |
| DraftCommentsReader draftCommentsReader) { | |
| this.commentsUtil = commentsUtil; | |
| this.notesFactory = notesFactory; | |
| this.factory = factory; | |
| this.draftCommentsReader = draftCommentsReader; | |
| } | |
| /** | |
| * Load the comment context of a single comment identified by its key. | |
| * | |
| * @param key a {@link CommentContextKey} identifying a comment. | |
| * @return the comment context associated with the comment. | |
| * @throws IOException an error happened while parsing the commit or loading the file where the | |
| * comment is written. | |
| */ | |
| @Override | |
| public CommentContext load(CommentContextKey key) throws IOException { | |
| return loadAll(ImmutableList.of(key)).get(key); | |
| } | |
| /** | |
| * Load the comment context of different comments identified by their keys. | |
| * | |
| * @param keys list of {@link CommentContextKey} identifying some comments. | |
| * @return a map of the input keys to their corresponding comment context. | |
| * @throws IOException an error happened while parsing the commits or loading the files where | |
| * the comments are written. | |
| */ | |
| @Override | |
| public Map<CommentContextKey, CommentContext> loadAll( | |
| Iterable<? extends CommentContextKey> keys) throws IOException { | |
| ImmutableMap.Builder<CommentContextKey, CommentContext> result = | |
| ImmutableMap.builderWithExpectedSize(Iterables.size(keys)); | |
| Map<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> groupedKeys = | |
| Streams.stream(keys) | |
| .distinct() | |
| .map(k -> (CommentContextKey) k) | |
| .collect( | |
| Collectors.groupingBy( | |
| CommentContextKey::project, | |
| Collectors.groupingBy(CommentContextKey::changeId))); | |
| for (Map.Entry<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> perProject : | |
| groupedKeys.entrySet()) { | |
| Map<Change.Id, List<CommentContextKey>> keysPerProject = perProject.getValue(); | |
| for (Map.Entry<Change.Id, List<CommentContextKey>> perChange : keysPerProject.entrySet()) { | |
| Map<CommentContextKey, CommentContext> context = | |
| loadForSameChange(perChange.getValue(), perProject.getKey(), perChange.getKey()); | |
| result.putAll(context); | |
| } | |
| } | |
| return result.build(); | |
| } | |
| /** | |
| * Load the comment context for comments (published and drafts) of the same project and change | |
| * ID. | |
| * | |
| * @param keys a list of keys corresponding to some comments | |
| * @param project a gerrit project/repository | |
| * @param changeId an identifier for a change | |
| * @return a map of the input keys to their corresponding {@link CommentContext} | |
| */ | |
| private Map<CommentContextKey, CommentContext> loadForSameChange( | |
| List<CommentContextKey> keys, Project.NameKey project, Change.Id changeId) | |
| throws IOException { | |
| ChangeNotes notes = notesFactory.createChecked(project, changeId); | |
| List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes); | |
| List<HumanComment> drafts = draftCommentsReader.getDraftsByChangeForAllAuthors(notes); | |
| List<HumanComment> allComments = | |
| Streams.concat(humanComments.stream(), drafts.stream()).collect(Collectors.toList()); | |
| CommentContextLoader loader = factory.create(project); | |
| Map<CommentContextKey, ContextInput> keysToComments = new HashMap<>(); | |
| for (CommentContextKey key : keys) { | |
| Comment comment = getCommentForKey(allComments, key); | |
| keysToComments.put(key, ContextInput.fromComment(comment, key.contextPadding())); | |
| } | |
| Map<ContextInput, CommentContext> allContext = | |
| loader.getContext( | |
| keysToComments.values().stream().distinct().collect(Collectors.toList())); | |
| return keys.stream() | |
| .collect( | |
| Collectors.toMap(Function.identity(), k -> allContext.get(keysToComments.get(k)))); | |
| } | |
| /** | |
| * Return the single comment from the {@code allComments} input list corresponding to the key | |
| * parameter. | |
| * | |
| * @param allComments a list of comments. | |
| * @param key a key representing a single comment. | |
| * @return the single comment corresponding to the key parameter. | |
| */ | |
| private Comment getCommentForKey(List<HumanComment> allComments, CommentContextKey key) { | |
| return allComments.stream() | |
| .filter( | |
| c -> | |
| key.id().equals(c.key.uuid) | |
| && key.patchset() == c.key.patchSetId | |
| && key.path().equals(hashPath(c.key.filename))) | |
| .findFirst() | |
| .orElseThrow(() -> new IllegalArgumentException("Unable to find comment for key " + key)); | |
| } | |
| /** | |
| * Hash an input String using the general {@link Hashing#murmur3_128()} hash. | |
| * | |
| * @param input the input String | |
| * @return a hashed representation of the input String | |
| */ | |
| static String hashPath(String input) { | |
| return Hashing.murmur3_128().hashString(input, UTF_8).toString(); | |
| } | |
| } | |
| private static class CommentContextWeigher implements Weigher<CommentContextKey, CommentContext> { | |
| @Override | |
| public int weigh(CommentContextKey key, CommentContext commentContext) { | |
| int size = 0; | |
| size += key.id().length(); | |
| size += key.path().length(); | |
| size += key.project().get().length(); | |
| size += 4; | |
| for (String line : commentContext.lines().values()) { | |
| size += 4; // line number | |
| size += line.length(); // number of characters in the context line | |
| } | |
| return size; | |
| } | |
| } | |
| } |