| // 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.server.account.externalids; | |
| import com.google.common.base.CharMatcher; | |
| import com.google.common.cache.Cache; | |
| import com.google.common.cache.CacheLoader; | |
| import com.google.common.collect.ImmutableSet; | |
| import com.google.common.collect.ImmutableSetMultimap; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.entities.Account; | |
| import com.google.gerrit.entities.RefNames; | |
| import com.google.gerrit.metrics.Counter1; | |
| 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.Timer0; | |
| import com.google.gerrit.server.config.AllUsersName; | |
| import com.google.gerrit.server.config.GerritServerConfig; | |
| import com.google.gerrit.server.git.GitRepositoryManager; | |
| 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.inject.Inject; | |
| import com.google.inject.Provider; | |
| import com.google.inject.Singleton; | |
| import com.google.inject.name.Named; | |
| import java.io.IOException; | |
| import java.util.HashMap; | |
| import java.util.HashSet; | |
| import java.util.Map; | |
| import java.util.Set; | |
| import java.util.concurrent.TimeUnit; | |
| import org.eclipse.jgit.errors.ConfigInvalidException; | |
| import org.eclipse.jgit.lib.Config; | |
| import org.eclipse.jgit.lib.ObjectId; | |
| import org.eclipse.jgit.lib.ObjectReader; | |
| import org.eclipse.jgit.lib.Ref; | |
| import org.eclipse.jgit.lib.Repository; | |
| import org.eclipse.jgit.revwalk.RevCommit; | |
| import org.eclipse.jgit.revwalk.RevWalk; | |
| import org.eclipse.jgit.treewalk.TreeWalk; | |
| import org.eclipse.jgit.treewalk.filter.TreeFilter; | |
| /** Loads cache values for the external ID cache using either a full or a partial reload. */ | |
| @Singleton | |
| public class ExternalIdCacheLoader extends CacheLoader<ObjectId, AllExternalIds> { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| // Maximum number of prior states we inspect to find a base for differential. If no cached state | |
| // is found within this number of parents, we fall back to reading everything from scratch. | |
| private static final int MAX_HISTORY_LOOKBACK = 10; | |
| private final ExternalIdReader externalIdReader; | |
| private final Provider<Cache<ObjectId, AllExternalIds>> externalIdCache; | |
| private final GitRepositoryManager gitRepositoryManager; | |
| private final AllUsersName allUsersName; | |
| private final Counter1<Boolean> reloadCounter; | |
| private final Timer0 reloadDifferential; | |
| private final boolean enablePartialReloads; | |
| private final boolean isPersistentCache; | |
| @Inject | |
| ExternalIdCacheLoader( | |
| GitRepositoryManager gitRepositoryManager, | |
| AllUsersName allUsersName, | |
| ExternalIdReader externalIdReader, | |
| @Named(ExternalIdCacheImpl.CACHE_NAME) | |
| Provider<Cache<ObjectId, AllExternalIds>> externalIdCache, | |
| MetricMaker metricMaker, | |
| @GerritServerConfig Config config) { | |
| this.externalIdReader = externalIdReader; | |
| this.externalIdCache = externalIdCache; | |
| this.gitRepositoryManager = gitRepositoryManager; | |
| this.allUsersName = allUsersName; | |
| this.reloadCounter = | |
| metricMaker.newCounter( | |
| "notedb/external_id_cache_load_count", | |
| new Description("Total number of external ID cache reloads from Git.") | |
| .setRate() | |
| .setUnit("updates"), | |
| Field.ofBoolean("partial", Metadata.Builder::partial).build()); | |
| this.reloadDifferential = | |
| metricMaker.newTimer( | |
| "notedb/external_id_partial_read_latency", | |
| new Description( | |
| "Latency for generating a new external ID cache state from a prior state.") | |
| .setCumulative() | |
| .setUnit(Units.MILLISECONDS)); | |
| this.enablePartialReloads = | |
| config.getBoolean("cache", ExternalIdCacheImpl.CACHE_NAME, "enablePartialReloads", true); | |
| this.isPersistentCache = | |
| config.getInt("cache", ExternalIdCacheImpl.CACHE_NAME, "diskLimit", 0) > 0; | |
| } | |
| @Override | |
| public AllExternalIds load(ObjectId notesRev) throws IOException, ConfigInvalidException { | |
| if (!enablePartialReloads) { | |
| logger.atInfo().log( | |
| "Partial reloads of " | |
| + ExternalIdCacheImpl.CACHE_NAME | |
| + " disabled. Falling back to full reload."); | |
| return reloadAllExternalIds(notesRev); | |
| } | |
| // The requested value was not in the cache (hence, this loader was invoked). Therefore, try to | |
| // create this entry from a past value using the minimal amount of Git operations possible to | |
| // reduce latency. | |
| // | |
| // First, try to find the most recent state we have in the cache. Most of the time, this will be | |
| // the state before the last update happened, but it can also date further back. We try a best | |
| // effort approach and check the last 10 states. If nothing is found, we default to loading the | |
| // value from scratch. | |
| // | |
| // If a prior state was found, we use Git to diff the trees and find modifications. This is | |
| // faster than just loading the complete current tree and working off of that because of how the | |
| // data is structured: NotesMaps use nested trees, so, for example, a NotesMap with 200k entries | |
| // has two layers of nesting: 12/34/1234..99. TreeWalk is smart in skipping the traversal of | |
| // identical subtrees. | |
| // | |
| // Once we know what files changed, we apply additions and removals to the previously cached | |
| // state. | |
| try (Repository repo = gitRepositoryManager.openRepository(allUsersName); | |
| RevWalk rw = new RevWalk(repo)) { | |
| long start = System.nanoTime(); | |
| Ref extIdRef = repo.exactRef(RefNames.REFS_EXTERNAL_IDS); | |
| if (extIdRef == null) { | |
| logger.atInfo().log( | |
| RefNames.REFS_EXTERNAL_IDS + " not initialized, falling back to full reload."); | |
| return reloadAllExternalIds(notesRev); | |
| } | |
| RevCommit currentCommit = rw.parseCommit(extIdRef.getObjectId()); | |
| rw.markStart(currentCommit); | |
| RevCommit parentWithCacheValue; | |
| AllExternalIds oldExternalIds = null; | |
| int i = 0; | |
| while ((parentWithCacheValue = rw.next()) != null | |
| && i++ < MAX_HISTORY_LOOKBACK | |
| && parentWithCacheValue.getParentCount() < 2) { | |
| oldExternalIds = externalIdCache.get().getIfPresent(parentWithCacheValue.getId()); | |
| if (oldExternalIds != null) { | |
| // We found a previously cached state. | |
| break; | |
| } | |
| } | |
| if (oldExternalIds == null) { | |
| if (isPersistentCache) { | |
| // If there is no persistence, this is normal. Don't upset admins reading the logs. | |
| logger.atWarning().log( | |
| "Unable to find an old ExternalId cache state, falling back to full reload"); | |
| } | |
| return reloadAllExternalIds(notesRev); | |
| } | |
| // Diff trees to recognize modifications | |
| Set<ObjectId> removals = new HashSet<>(); // Set<Blob-Object-Id> | |
| Map<ObjectId, ObjectId> additions = new HashMap<>(); // Map<Name-ObjectId, Blob-Object-Id> | |
| try (TreeWalk treeWalk = new TreeWalk(repo)) { | |
| treeWalk.setFilter(TreeFilter.ANY_DIFF); | |
| treeWalk.setRecursive(true); | |
| treeWalk.reset(parentWithCacheValue.getTree(), currentCommit.getTree()); | |
| while (treeWalk.next()) { | |
| String path = treeWalk.getPathString(); | |
| ObjectId oldBlob = treeWalk.getObjectId(0); | |
| ObjectId newBlob = treeWalk.getObjectId(1); | |
| if (ObjectId.zeroId().equals(newBlob)) { | |
| // Deletion | |
| removals.add(oldBlob); | |
| } else if (ObjectId.zeroId().equals(oldBlob)) { | |
| // Addition | |
| additions.put(fileNameToObjectId(path), newBlob); | |
| } else { | |
| // Modification | |
| removals.add(oldBlob); | |
| additions.put(fileNameToObjectId(path), newBlob); | |
| } | |
| } | |
| } | |
| AllExternalIds allExternalIds = | |
| buildAllExternalIds(repo, oldExternalIds, additions, removals); | |
| reloadCounter.increment(true); | |
| reloadDifferential.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); | |
| return allExternalIds; | |
| } | |
| } | |
| private static ObjectId fileNameToObjectId(String path) { | |
| return ObjectId.fromString(CharMatcher.is('/').removeFrom(path)); | |
| } | |
| /** | |
| * Build a new {@link AllExternalIds} from an old state by applying additions and removals that | |
| * were performed since then. | |
| * | |
| * <p>Removals are applied before additions. | |
| * | |
| * @param repo open repository | |
| * @param oldExternalIds prior state that is used as base | |
| * @param additions map of name to blob ID for each external ID that should be added | |
| * @param removals set of name {@link ObjectId}s that should be removed | |
| */ | |
| private static AllExternalIds buildAllExternalIds( | |
| Repository repo, | |
| AllExternalIds oldExternalIds, | |
| Map<ObjectId, ObjectId> additions, | |
| Set<ObjectId> removals) | |
| throws IOException { | |
| ImmutableSetMultimap.Builder<Account.Id, ExternalId> byAccount = ImmutableSetMultimap.builder(); | |
| ImmutableSetMultimap.Builder<String, ExternalId> byEmail = ImmutableSetMultimap.builder(); | |
| // Copy over old ExternalIds but exclude deleted ones | |
| for (ExternalId externalId : oldExternalIds.byAccount().values()) { | |
| if (removals.contains(externalId.blobId())) { | |
| continue; | |
| } | |
| byAccount.put(externalId.accountId(), externalId); | |
| if (externalId.email() != null) { | |
| byEmail.put(externalId.email(), externalId); | |
| } | |
| } | |
| // Add newly discovered ExternalIds | |
| try (ObjectReader reader = repo.newObjectReader()) { | |
| for (Map.Entry<ObjectId, ObjectId> nameToBlob : additions.entrySet()) { | |
| ExternalId parsedExternalId; | |
| try { | |
| parsedExternalId = | |
| ExternalId.parse( | |
| nameToBlob.getKey().name(), | |
| reader.open(nameToBlob.getValue()).getCachedBytes(), | |
| nameToBlob.getValue()); | |
| } catch (ConfigInvalidException | RuntimeException e) { | |
| logger.atSevere().withCause(e).log( | |
| "Ignoring invalid external ID note %s", nameToBlob.getKey().name()); | |
| continue; | |
| } | |
| byAccount.put(parsedExternalId.accountId(), parsedExternalId); | |
| if (parsedExternalId.email() != null) { | |
| byEmail.put(parsedExternalId.email(), parsedExternalId); | |
| } | |
| } | |
| } | |
| return new AutoValue_AllExternalIds(byAccount.build(), byEmail.build()); | |
| } | |
| private AllExternalIds reloadAllExternalIds(ObjectId notesRev) | |
| throws IOException, ConfigInvalidException { | |
| try (TraceTimer ignored = | |
| TraceContext.newTimer( | |
| "Loading external IDs from scratch", | |
| Metadata.builder().revision(notesRev.name()).build())) { | |
| ImmutableSet<ExternalId> externalIds = externalIdReader.all(notesRev); | |
| externalIds.forEach(ExternalId::checkThatBlobIdIsSet); | |
| AllExternalIds allExternalIds = AllExternalIds.create(externalIds); | |
| reloadCounter.increment(false); | |
| return allExternalIds; | |
| } | |
| } | |
| } |