| // Copyright (C) 2023 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.git; |
| |
| import com.google.auto.value.AutoValue; |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.cache.Cache; |
| import com.google.common.cache.Weigher; |
| import com.google.common.collect.HashBasedTable; |
| import com.google.common.collect.Table; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.errorprone.annotations.CanIgnoreReturnValue; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.entities.BranchNameKey; |
| import com.google.gerrit.entities.Change; |
| import com.google.gerrit.entities.Project; |
| import com.google.gerrit.entities.converter.AccountIdProtoConverter; |
| import com.google.gerrit.entities.converter.ChangeProtoConverter; |
| import com.google.gerrit.proto.Entities.Project_NameKey; |
| import com.google.gerrit.proto.Protos; |
| import com.google.gerrit.server.ReviewerSet; |
| import com.google.gerrit.server.cache.CacheModule; |
| import com.google.gerrit.server.cache.proto.Cache.CachedProjectChangesProto; |
| import com.google.gerrit.server.cache.proto.Cache.CachedProjectChangesProto.NonPrivateChangesProto; |
| import com.google.gerrit.server.cache.proto.Cache.CachedProjectChangesProto.PrivateChangeProto; |
| import com.google.gerrit.server.cache.proto.Cache.ReviewerSetProto; |
| import com.google.gerrit.server.cache.serialize.CacheSerializer; |
| import com.google.gerrit.server.cache.serialize.ObjectIdConverter; |
| import com.google.gerrit.server.cache.serialize.ProtobufSerializer; |
| import com.google.gerrit.server.index.change.ChangeField; |
| import com.google.gerrit.server.logging.Metadata; |
| import com.google.gerrit.server.logging.TraceContext; |
| import com.google.gerrit.server.logging.TraceContext.TraceTimer; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.notedb.ReviewerStateInternal; |
| import com.google.gerrit.server.query.change.ChangeData; |
| import com.google.gerrit.server.query.change.InternalChangeQuery; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| import com.google.inject.name.Named; |
| import com.google.protobuf.ByteString; |
| import java.io.IOException; |
| import java.time.Instant; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.concurrent.ConcurrentHashMap; |
| import java.util.concurrent.ExecutionException; |
| import java.util.stream.Stream; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.Repository; |
| |
| /** |
| * Lightweight cache of changes in each project. |
| * |
| * <p>This cache is intended to be used when filtering references and stores only the minimal fields |
| * required for a read permission check. |
| */ |
| @Singleton |
| public class ChangesByProjectCacheImpl implements ChangesByProjectCache { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private static final String CACHE_NAME = "changes_by_project"; |
| |
| public static class Module extends CacheModule { |
| @Override |
| protected void configure() { |
| persist(CACHE_NAME, Project_NameKey.class, CachedProjectChanges.class) |
| .weigher(ChangesByProjetCacheWeigher.class) |
| .diskLimit(1 << 30) // 1 GiB |
| .keySerializer(new ProtobufSerializer<>(Project_NameKey.parser())) |
| .valueSerializer(CachedProjectChangesSerializer.INSTANCE) |
| .version(1); |
| |
| bind(ChangesByProjectCache.class).to(ChangesByProjectCacheImpl.class); |
| } |
| } |
| |
| private final Cache<Project_NameKey, CachedProjectChanges> cache; |
| private final ChangeData.Factory cdFactory; |
| private final UseIndex useIndex; |
| private final Provider<InternalChangeQuery> queryProvider; |
| |
| @Inject |
| ChangesByProjectCacheImpl( |
| @Named(CACHE_NAME) Cache<Project_NameKey, CachedProjectChanges> cache, |
| ChangeData.Factory cdFactory, |
| UseIndex useIndex, |
| Provider<InternalChangeQuery> queryProvider) { |
| this.cache = cache; |
| this.cdFactory = cdFactory; |
| this.useIndex = useIndex; |
| this.queryProvider = queryProvider; |
| } |
| |
| /** {@inheritDoc} */ |
| @Override |
| public Stream<ChangeData> streamChangeDatas(Project.NameKey project, Repository repo) |
| throws IOException { |
| Project_NameKey projectProto = Project_NameKey.newBuilder().setName(project.get()).build(); |
| CachedProjectChanges projectChanges = cache.getIfPresent(projectProto); |
| if (projectChanges != null) { |
| CachedProjectChanges.ChangeDataUpdateResult result = |
| projectChanges.updateChangeDatas( |
| project, cdFactory, ChangeNotes.Factory.scanChangeIds(repo), "Updating"); |
| if (result.anyUpdated()) { |
| cache.put(projectProto, projectChanges); |
| } |
| return result.cds().stream(); |
| } |
| if (UseIndex.TRUE.equals(useIndex)) { |
| return queryChangeDatasAndLoad(projectProto).stream(); |
| } |
| return scanChangeDatasAndLoad(projectProto, repo).stream(); |
| } |
| |
| private Collection<ChangeData> scanChangeDatasAndLoad( |
| Project_NameKey projectProto, Repository repo) throws IOException { |
| CachedProjectChanges ours = new CachedProjectChanges(); |
| CachedProjectChanges projectChanges = ours; |
| try { |
| projectChanges = cache.get(projectProto, () -> ours); |
| } catch (ExecutionException e) { |
| logger.atWarning().withCause(e).log( |
| "Cannot load %s for %s", CACHE_NAME, projectProto.getName()); |
| } |
| return projectChanges |
| .updateChangeDatas( |
| Project.NameKey.parse(projectProto.getName()), |
| cdFactory, |
| ChangeNotes.Factory.scanChangeIds(repo), |
| ours == projectChanges ? "Scanning" : "Updating") |
| .cds(); |
| } |
| |
| private List<ChangeData> queryChangeDatasAndLoad(Project_NameKey projectProto) { |
| Project.NameKey project = Project.NameKey.parse(projectProto.getName()); |
| List<ChangeData> cds = queryChangeDatas(project); |
| cache.put(projectProto, new CachedProjectChanges(cds)); |
| return cds; |
| } |
| |
| private List<ChangeData> queryChangeDatas(Project.NameKey project) { |
| try (TraceTimer timer = |
| TraceContext.newTimer( |
| "Querying changes of project", Metadata.builder().projectName(project.get()).build())) { |
| return queryProvider |
| .get() |
| .setRequestedFields( |
| ChangeField.CHANGE_SPEC, ChangeField.REVIEWER_SPEC, ChangeField.REF_STATE_SPEC) |
| .byProject(project); |
| } |
| } |
| |
| @VisibleForTesting |
| public static class CachedProjectChanges { |
| public record ChangeDataUpdateResult(Collection<ChangeData> cds, boolean anyUpdated) {} |
| |
| Map<String, Map<Change.Id, ObjectId>> metaObjectIdByNonPrivateChangeByBranch = |
| new ConcurrentHashMap<>(); // BranchNameKey "normalized" to a String to dedup project |
| Map<Change.Id, PrivateChange> privateChangeById = new ConcurrentHashMap<>(); |
| |
| public CachedProjectChanges() {} |
| |
| public CachedProjectChanges(Collection<ChangeData> cds) { |
| cds.stream().forEach(cd -> insert(cd)); |
| } |
| |
| public ChangeDataUpdateResult updateChangeDatas( |
| Project.NameKey project, |
| ChangeData.Factory cdFactory, |
| Map<Change.Id, ObjectId> metaObjectIdByChange, |
| String operation) { |
| boolean anyUpdated = false; |
| try (TraceTimer timer = |
| TraceContext.newTimer( |
| operation + " changes of project", |
| Metadata.builder().projectName(project.get()).build())) { |
| Map<Change.Id, ChangeData> cachedCdByChange = getChangeDataByChange(project, cdFactory); |
| List<ChangeData> cds = new ArrayList<>(); |
| for (Map.Entry<Change.Id, ObjectId> e : metaObjectIdByChange.entrySet()) { |
| Change.Id id = e.getKey(); |
| ChangeData cached = cachedCdByChange.get(id); |
| ChangeData cd = cached; |
| try { |
| if (cd == null || !cached.metaRevisionOrThrow().equals(e.getValue())) { |
| cd = cdFactory.create(project, id); |
| update(cached, cd); |
| anyUpdated = true; |
| } |
| } catch (Exception ex) { |
| anyUpdated = true; |
| // Do not let a bad change prevent other changes from being available. |
| logger.atFinest().withCause(ex).log("Can't load changeData for %s", id); |
| continue; |
| } |
| cds.add(cd); |
| } |
| return new ChangeDataUpdateResult(cds, anyUpdated); |
| } |
| } |
| |
| @CanIgnoreReturnValue |
| public CachedProjectChanges update(ChangeData old, ChangeData updated) { |
| if (old != null) { |
| if (old.isPrivateOrThrow()) { |
| privateChangeById.remove(old.getId()); |
| } else { |
| Map<Change.Id, ObjectId> metaObjectIdByNonPrivateChange = |
| metaObjectIdByNonPrivateChangeByBranch.get(old.branchOrThrow().branch()); |
| if (metaObjectIdByNonPrivateChange != null) { |
| metaObjectIdByNonPrivateChange.remove(old.getId()); |
| } |
| } |
| } |
| return insert(updated); |
| } |
| |
| @CanIgnoreReturnValue |
| public CachedProjectChanges insert(ChangeData cd) { |
| if (cd.isPrivateOrThrow()) { |
| privateChangeById.put( |
| cd.getId(), |
| new AutoValue_ChangesByProjectCacheImpl_PrivateChange( |
| cd.change(), cd.reviewers(), cd.metaRevisionOrThrow())); |
| } else { |
| metaObjectIdByNonPrivateChangeByBranch |
| .computeIfAbsent(cd.branchOrThrow().branch(), b -> new ConcurrentHashMap<>()) |
| .put(cd.getId(), cd.metaRevisionOrThrow()); |
| } |
| return this; |
| } |
| |
| public Map<Change.Id, ChangeData> getChangeDataByChange( |
| Project.NameKey project, ChangeData.Factory cdFactory) { |
| Map<Change.Id, ChangeData> cdByChange = new HashMap<>(privateChangeById.size()); |
| for (PrivateChange pc : privateChangeById.values()) { |
| try { |
| ChangeData cd = cdFactory.create(pc.change()); |
| cd.setReviewers(pc.reviewers()); |
| cd.setMetaRevision(pc.metaRevision()); |
| cdByChange.put(cd.getId(), cd); |
| } catch (Exception ex) { |
| // Do not let a bad change prevent other changes from being available. |
| logger.atFinest().withCause(ex).log("Can't load changeData for %s", pc.change().getId()); |
| } |
| } |
| |
| for (Map.Entry<String, Map<Change.Id, ObjectId>> e : |
| metaObjectIdByNonPrivateChangeByBranch.entrySet()) { |
| BranchNameKey branch = BranchNameKey.create(project, e.getKey()); |
| for (Map.Entry<Change.Id, ObjectId> e2 : e.getValue().entrySet()) { |
| Change.Id id = e2.getKey(); |
| try { |
| cdByChange.put(id, cdFactory.createNonPrivate(branch, id, e2.getValue())); |
| } catch (Exception ex) { |
| // Do not let a bad change prevent other changes from being available. |
| logger.atFinest().withCause(ex).log("Can't load changeData for %s", id); |
| } |
| } |
| } |
| return cdByChange; |
| } |
| |
| public int weigh() { |
| int size = 0; |
| size += 24 * 2; // guess at basic ConcurrentHashMap overhead * 2 |
| for (Map.Entry<String, Map<Change.Id, ObjectId>> e : |
| metaObjectIdByNonPrivateChangeByBranch.entrySet()) { |
| size += JavaWeights.REFERENCE + e.getKey().length(); |
| size += |
| e.getValue().size() |
| * (JavaWeights.REFERENCE |
| + JavaWeights.OBJECT // Map.Entry |
| + JavaWeights.REFERENCE |
| + GerritWeights.CHANGE_NUM |
| + JavaWeights.REFERENCE |
| + GerritWeights.OBJECTID); |
| } |
| for (Map.Entry<Change.Id, PrivateChange> e : privateChangeById.entrySet()) { |
| size += JavaWeights.REFERENCE + GerritWeights.CHANGE_NUM; |
| size += JavaWeights.REFERENCE + e.getValue().weigh(); |
| } |
| return size; |
| } |
| } |
| |
| @AutoValue |
| abstract static class PrivateChange { |
| // Fields needed to serve permission checks on private Changes |
| abstract Change change(); |
| |
| @Nullable |
| abstract ReviewerSet reviewers(); |
| |
| abstract ObjectId metaRevision(); // Needed to confirm whether up-to-date |
| |
| public int weigh() { |
| int size = 0; |
| size += JavaWeights.OBJECT; // this |
| size += JavaWeights.REFERENCE + weigh(change()); |
| size += JavaWeights.REFERENCE + weigh(reviewers()); |
| size += JavaWeights.REFERENCE + GerritWeights.OBJECTID; // metaRevision |
| return size; |
| } |
| |
| private static int weigh(Change c) { |
| int size = 0; |
| size += JavaWeights.OBJECT; // change |
| size += JavaWeights.REFERENCE + GerritWeights.KEY_INT; // changeId |
| size += JavaWeights.REFERENCE + (c.getServerId() == null ? 0 : c.getServerId().length()); |
| size += JavaWeights.REFERENCE + JavaWeights.OBJECT + 40; // changeKey; |
| size += JavaWeights.REFERENCE + GerritWeights.TIMESTAMP; // createdOn; |
| size += JavaWeights.REFERENCE + GerritWeights.TIMESTAMP; // lastUpdatedOn; |
| size += JavaWeights.REFERENCE + GerritWeights.ACCOUNT_ID; // owner; |
| size += |
| JavaWeights.REFERENCE |
| + c.getDest().project().get().length() |
| + c.getDest().branch().length(); |
| size += JavaWeights.CHAR; // status; |
| size += JavaWeights.INT; // currentPatchSetId; |
| size += JavaWeights.REFERENCE + c.getSubject().length(); |
| size += JavaWeights.REFERENCE + (c.getTopic() == null ? 0 : c.getTopic().length()); |
| size += |
| JavaWeights.REFERENCE |
| + (c.getOriginalSubject().equals(c.getSubject()) |
| ? 0 |
| : c.getOriginalSubject().length()); |
| size += |
| JavaWeights.REFERENCE + (c.getSubmissionId() == null ? 0 : c.getSubmissionId().length()); |
| size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // isPrivate; |
| size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // workInProgress; |
| size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // reviewStarted; |
| size += JavaWeights.REFERENCE + (c.getRevertOf() == null ? 0 : GerritWeights.CHANGE_NUM); |
| size += |
| JavaWeights.REFERENCE + (c.getCherryPickOf() == null ? 0 : GerritWeights.PATCH_SET_ID); |
| return size; |
| } |
| |
| private static int weigh(ReviewerSet rs) { |
| int size = 0; |
| size += JavaWeights.OBJECT; // ReviewerSet |
| size += JavaWeights.REFERENCE; // table |
| size += |
| rs.asTable().cellSet().size() |
| * (JavaWeights.OBJECT // cell (at least one object) |
| + JavaWeights.REFERENCE // ReviewerStateInternal |
| + (JavaWeights.REFERENCE + GerritWeights.ACCOUNT_ID) |
| + (JavaWeights.REFERENCE + GerritWeights.TIMESTAMP)); |
| size += JavaWeights.REFERENCE; // accounts |
| return size; |
| } |
| } |
| |
| private static class ChangesByProjetCacheWeigher |
| implements Weigher<Project_NameKey, CachedProjectChanges> { |
| @Override |
| public int weigh(Project_NameKey project, CachedProjectChanges changes) { |
| int size = 0; |
| size += project.getName().length(); |
| size += changes.weigh(); |
| return size; |
| } |
| } |
| |
| private static class GerritWeights { |
| public static final int KEY_INT = JavaWeights.OBJECT + JavaWeights.INT; // IntKey |
| public static final int CHANGE_NUM = KEY_INT; |
| public static final int ACCOUNT_ID = KEY_INT; |
| public static final int PATCH_SET_ID = |
| JavaWeights.OBJECT |
| + (JavaWeights.REFERENCE + GerritWeights.CHANGE_NUM) // PatchSet.Id.changeId |
| + JavaWeights.INT; // PatchSet.Id patch_num; |
| public static final int TIMESTAMP = JavaWeights.OBJECT + 8; // Timestamp |
| public static final int OBJECTID = JavaWeights.OBJECT + (5 * JavaWeights.INT); // (w1-w5) |
| } |
| |
| private static class JavaWeights { |
| public static final int BOOLEAN = 1; |
| public static final int CHAR = 1; |
| public static final int INT = 4; |
| public static final int OBJECT = 16; |
| public static final int REFERENCE = 8; |
| } |
| |
| static class ReviewerSetSerializer { |
| public static ReviewerSetProto serialize(ReviewerSet reviewerSet) { |
| ReviewerSetProto.Builder builder = ReviewerSetProto.newBuilder(); |
| |
| reviewerSet |
| .asTable() |
| .cellSet() |
| .forEach( |
| cell -> { |
| ReviewerSetProto.ReviewerProto.Builder reviewerBuilder = |
| ReviewerSetProto.ReviewerProto.newBuilder() |
| .setAccountId( |
| AccountIdProtoConverter.INSTANCE.toProto( |
| Account.id(cell.getColumnKey().get()))) |
| .setState(cell.getRowKey().name()) |
| .setTimestamp(cell.getValue().toEpochMilli()); |
| |
| builder.addReviewers(reviewerBuilder.build()); |
| }); |
| |
| return builder.build(); |
| } |
| |
| public static ReviewerSet deserialize(ReviewerSetProto proto) { |
| Table<ReviewerStateInternal, Account.Id, Instant> table = HashBasedTable.create(); |
| |
| for (ReviewerSetProto.ReviewerProto reviewer : proto.getReviewersList()) { |
| Account.Id accountId = AccountIdProtoConverter.INSTANCE.fromProto(reviewer.getAccountId()); |
| ReviewerStateInternal state = ReviewerStateInternal.valueOf(reviewer.getState()); |
| Instant timestamp = Instant.ofEpochMilli(reviewer.getTimestamp()); |
| |
| table.put(state, accountId, timestamp); |
| } |
| |
| return ReviewerSet.fromTable(table); |
| } |
| } |
| |
| enum CachedProjectChangesSerializer implements CacheSerializer<CachedProjectChanges> { |
| INSTANCE; |
| |
| @Override |
| public byte[] serialize(CachedProjectChanges value) { |
| CachedProjectChangesProto.Builder protoBuilder = CachedProjectChangesProto.newBuilder(); |
| |
| for (Map.Entry<String, Map<Change.Id, ObjectId>> branchEntry : |
| value.metaObjectIdByNonPrivateChangeByBranch.entrySet()) { |
| NonPrivateChangesProto.Builder nonPrivateChangesBuilder = |
| NonPrivateChangesProto.newBuilder(); |
| |
| for (Map.Entry<Change.Id, ObjectId> changeEntry : branchEntry.getValue().entrySet()) { |
| nonPrivateChangesBuilder.putChanges( |
| changeEntry.getKey().get(), |
| ObjectIdConverter.create().toByteString(changeEntry.getValue())); |
| } |
| |
| protoBuilder.putNonPrivateChangesByBranch( |
| branchEntry.getKey(), nonPrivateChangesBuilder.build()); |
| } |
| |
| for (Map.Entry<Change.Id, PrivateChange> entry : value.privateChangeById.entrySet()) { |
| PrivateChange pc = entry.getValue(); |
| PrivateChangeProto.Builder privateChangeBuilder = PrivateChangeProto.newBuilder(); |
| |
| privateChangeBuilder.setChange(ChangeProtoConverter.INSTANCE.toProto(pc.change())); |
| |
| if (pc.reviewers() != null) { |
| privateChangeBuilder.setReviewers(ReviewerSetSerializer.serialize(pc.reviewers())); |
| } |
| |
| privateChangeBuilder.setMetaRevision( |
| ObjectIdConverter.create().toByteString(pc.metaRevision())); |
| |
| protoBuilder.putPrivateChanges(entry.getKey().get(), privateChangeBuilder.build()); |
| } |
| |
| return Protos.toByteArray(protoBuilder.build()); |
| } |
| |
| @Override |
| public CachedProjectChanges deserialize(byte[] in) { |
| try { |
| CachedProjectChangesProto proto = |
| Protos.parseUnchecked(CachedProjectChangesProto.parser(), in); |
| |
| CachedProjectChanges result = new CachedProjectChanges(); |
| |
| for (Map.Entry<String, NonPrivateChangesProto> branchEntry : |
| proto.getNonPrivateChangesByBranchMap().entrySet()) { |
| |
| Map<Change.Id, ObjectId> changesMap = new ConcurrentHashMap<>(); |
| for (Map.Entry<Integer, ByteString> changeEntry : |
| branchEntry.getValue().getChangesMap().entrySet()) { |
| |
| changesMap.put( |
| Change.id(changeEntry.getKey()), |
| ObjectId.fromRaw(changeEntry.getValue().toByteArray())); |
| } |
| |
| result.metaObjectIdByNonPrivateChangeByBranch.put(branchEntry.getKey(), changesMap); |
| } |
| |
| for (Map.Entry<Integer, PrivateChangeProto> entry : |
| proto.getPrivateChangesMap().entrySet()) { |
| Change.Id id = Change.id(entry.getKey()); |
| PrivateChangeProto privateChangeProto = entry.getValue(); |
| |
| Change change = ChangeProtoConverter.INSTANCE.fromProto(privateChangeProto.getChange()); |
| ReviewerSet reviewers = |
| ReviewerSetSerializer.deserialize(privateChangeProto.getReviewers()); |
| ObjectId metaRevision = |
| ObjectId.fromRaw(privateChangeProto.getMetaRevision().toByteArray()); |
| |
| result.privateChangeById.put( |
| id, |
| new AutoValue_ChangesByProjectCacheImpl_PrivateChange( |
| change, reviewers, metaRevision)); |
| } |
| |
| return result; |
| } catch (Exception e) { |
| logger.atWarning().withCause(e).log("Failed to deserialize CachedProjectChanges"); |
| return new CachedProjectChanges(); |
| } |
| } |
| } |
| } |