summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java')
-rw-r--r--java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java974
1 files changed, 974 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
new file mode 100644
index 0000000000..76e7f81ad7
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -0,0 +1,974 @@
+// Copyright (C) 2017 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 static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * {@link VersionedMetaData} subclass to update external IDs.
+ *
+ * <p>This is a low-level API. Read/write of external IDs should be done through {@link
+ * com.google.gerrit.server.account.AccountsUpdate} or {@link
+ * com.google.gerrit.server.account.AccountConfig}.
+ *
+ * <p>On load the note map from {@code refs/meta/external-ids} is read, but the external IDs are not
+ * parsed yet (see {@link #onLoad()}).
+ *
+ * <p>After loading the note map callers can access single or all external IDs. Only now the
+ * requested external IDs are parsed.
+ *
+ * <p>After loading the note map callers can stage various external ID updates (insert, upsert,
+ * delete, replace).
+ *
+ * <p>On save the staged external ID updates are performed (see {@link #onSave(CommitBuilder)}).
+ *
+ * <p>After committing the external IDs a cache update can be requested which also reindexes the
+ * accounts for which external IDs have been updated (see {@link #updateCaches()}).
+ */
+public class ExternalIdNotes extends VersionedMetaData {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private static final int MAX_NOTE_SZ = 1 << 19;
+
+ public interface ExternalIdNotesLoader {
+ /**
+ * Loads the external ID notes from the current tip of the {@code refs/meta/external-ids}
+ * branch.
+ *
+ * @param allUsersRepo the All-Users repository
+ */
+ ExternalIdNotes load(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+
+ /**
+ * Loads the external ID notes from the specified revision of the {@code refs/meta/external-ids}
+ * branch.
+ *
+ * @param allUsersRepo the All-Users repository
+ * @param rev the revision from which the external ID notes should be loaded, if {@code null}
+ * the external ID notes are loaded from the current tip, if {@link ObjectId#zeroId()} it's
+ * assumed that the {@code refs/meta/external-ids} branch doesn't exist and the loaded
+ * external IDs will be empty
+ */
+ ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
+ throws IOException, ConfigInvalidException;
+ }
+
+ @Singleton
+ public static class Factory implements ExternalIdNotesLoader {
+ private final ExternalIdCache externalIdCache;
+ private final AccountCache accountCache;
+ private final Provider<AccountIndexer> accountIndexer;
+ private final MetricMaker metricMaker;
+ private final AllUsersName allUsersName;
+
+ @Inject
+ Factory(
+ ExternalIdCache externalIdCache,
+ AccountCache accountCache,
+ Provider<AccountIndexer> accountIndexer,
+ MetricMaker metricMaker,
+ AllUsersName allUsersName) {
+ this.externalIdCache = externalIdCache;
+ this.accountCache = accountCache;
+ this.accountIndexer = accountIndexer;
+ this.metricMaker = metricMaker;
+ this.allUsersName = allUsersName;
+ }
+
+ @Override
+ public ExternalIdNotes load(Repository allUsersRepo)
+ throws IOException, ConfigInvalidException {
+ return new ExternalIdNotes(
+ externalIdCache,
+ accountCache,
+ accountIndexer,
+ metricMaker,
+ allUsersName,
+ allUsersRepo)
+ .load();
+ }
+
+ @Override
+ public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
+ throws IOException, ConfigInvalidException {
+ return new ExternalIdNotes(
+ externalIdCache,
+ accountCache,
+ accountIndexer,
+ metricMaker,
+ allUsersName,
+ allUsersRepo)
+ .load(rev);
+ }
+ }
+
+ @Singleton
+ public static class FactoryNoReindex implements ExternalIdNotesLoader {
+ private final ExternalIdCache externalIdCache;
+ private final MetricMaker metricMaker;
+ private final AllUsersName allUsersName;
+
+ @Inject
+ FactoryNoReindex(
+ ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
+ this.externalIdCache = externalIdCache;
+ this.metricMaker = metricMaker;
+ this.allUsersName = allUsersName;
+ }
+
+ @Override
+ public ExternalIdNotes load(Repository allUsersRepo)
+ throws IOException, ConfigInvalidException {
+ return new ExternalIdNotes(
+ externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
+ .load();
+ }
+
+ @Override
+ public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
+ throws IOException, ConfigInvalidException {
+ return new ExternalIdNotes(
+ externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
+ .load(rev);
+ }
+ }
+
+ /**
+ * Loads the external ID notes for reading only. The external ID notes are loaded from the current
+ * tip of the {@code refs/meta/external-ids} branch.
+ *
+ * @return read-only {@link ExternalIdNotes} instance
+ */
+ public static ExternalIdNotes loadReadOnly(AllUsersName allUsersName, Repository allUsersRepo)
+ throws IOException, ConfigInvalidException {
+ return new ExternalIdNotes(
+ new DisabledExternalIdCache(),
+ null,
+ null,
+ new DisabledMetricMaker(),
+ allUsersName,
+ allUsersRepo)
+ .setReadOnly()
+ .load();
+ }
+
+ /**
+ * Loads the external ID notes for reading only. The external ID notes are loaded from the
+ * specified revision of the {@code refs/meta/external-ids} branch.
+ *
+ * @param rev the revision from which the external ID notes should be loaded, if {@code null} the
+ * external ID notes are loaded from the current tip, if {@link ObjectId#zeroId()} it's
+ * assumed that the {@code refs/meta/external-ids} branch doesn't exist and the loaded
+ * external IDs will be empty
+ * @return read-only {@link ExternalIdNotes} instance
+ */
+ public static ExternalIdNotes loadReadOnly(
+ AllUsersName allUsersName, Repository allUsersRepo, @Nullable ObjectId rev)
+ throws IOException, ConfigInvalidException {
+ return new ExternalIdNotes(
+ new DisabledExternalIdCache(),
+ null,
+ null,
+ new DisabledMetricMaker(),
+ allUsersName,
+ allUsersRepo)
+ .setReadOnly()
+ .load(rev);
+ }
+
+ /**
+ * Loads the external ID notes for updates without cache evictions. The external ID notes are
+ * loaded from the current tip of the {@code refs/meta/external-ids} branch.
+ *
+ * <p>Use this only from init, schema upgrades and tests.
+ *
+ * <p>Metrics are disabled.
+ *
+ * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
+ */
+ public static ExternalIdNotes loadNoCacheUpdate(
+ AllUsersName allUsersName, Repository allUsersRepo)
+ throws IOException, ConfigInvalidException {
+ return new ExternalIdNotes(
+ new DisabledExternalIdCache(),
+ null,
+ null,
+ new DisabledMetricMaker(),
+ allUsersName,
+ allUsersRepo)
+ .load();
+ }
+
+ private final ExternalIdCache externalIdCache;
+ @Nullable private final AccountCache accountCache;
+ @Nullable private final Provider<AccountIndexer> accountIndexer;
+ private final AllUsersName allUsersName;
+ private final Counter0 updateCount;
+ private final Repository repo;
+
+ private NoteMap noteMap;
+ private ObjectId oldRev;
+
+ // Staged note map updates that should be executed on save.
+ private List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
+
+ // Staged cache updates that should be executed after external ID changes have been committed.
+ private List<CacheUpdate> cacheUpdates = new ArrayList<>();
+
+ private Runnable afterReadRevision;
+ private boolean readOnly = false;
+
+ private ExternalIdNotes(
+ ExternalIdCache externalIdCache,
+ @Nullable AccountCache accountCache,
+ @Nullable Provider<AccountIndexer> accountIndexer,
+ MetricMaker metricMaker,
+ AllUsersName allUsersName,
+ Repository allUsersRepo) {
+ this.externalIdCache = requireNonNull(externalIdCache, "externalIdCache");
+ this.accountCache = accountCache;
+ this.accountIndexer = accountIndexer;
+ this.updateCount =
+ metricMaker.newCounter(
+ "notedb/external_id_update_count",
+ new Description("Total number of external ID updates.").setRate().setUnit("updates"));
+ this.allUsersName = requireNonNull(allUsersName, "allUsersRepo");
+ this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
+ }
+
+ public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) {
+ this.afterReadRevision = afterReadRevision;
+ return this;
+ }
+
+ private ExternalIdNotes setReadOnly() {
+ this.readOnly = true;
+ return this;
+ }
+
+ public Repository getRepository() {
+ return repo;
+ }
+
+ @Override
+ protected String getRefName() {
+ return RefNames.REFS_EXTERNAL_IDS;
+ }
+
+ /**
+ * Loads the external ID notes from the current tip of the {@code refs/meta/external-ids} branch.
+ *
+ * @return {@link ExternalIdNotes} instance for chaining
+ */
+ private ExternalIdNotes load() throws IOException, ConfigInvalidException {
+ load(allUsersName, repo);
+ return this;
+ }
+
+ /**
+ * Loads the external ID notes from the specified revision of the {@code refs/meta/external-ids}
+ * branch.
+ *
+ * @param rev the revision from which the external ID notes should be loaded, if {@code null} the
+ * external ID notes are loaded from the current tip, if {@link ObjectId#zeroId()} it's
+ * assumed that the {@code refs/meta/external-ids} branch doesn't exist and the loaded
+ * external IDs will be empty
+ * @return {@link ExternalIdNotes} instance for chaining
+ */
+ ExternalIdNotes load(@Nullable ObjectId rev) throws IOException, ConfigInvalidException {
+ if (rev == null) {
+ return load();
+ }
+ if (ObjectId.zeroId().equals(rev)) {
+ load(allUsersName, repo, null);
+ return this;
+ }
+ load(allUsersName, repo, rev);
+ return this;
+ }
+
+ /**
+ * Parses and returns the specified external ID.
+ *
+ * @param key the key of the external ID
+ * @return the external ID, {@code Optional.empty()} if it doesn't exist
+ */
+ public Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+ checkLoaded();
+ ObjectId noteId = key.sha1();
+ if (!noteMap.contains(noteId)) {
+ return Optional.empty();
+ }
+
+ try (RevWalk rw = new RevWalk(repo)) {
+ ObjectId noteDataId = noteMap.get(noteId);
+ byte[] raw = readNoteData(rw, noteDataId);
+ return Optional.of(ExternalId.parse(noteId.name(), raw, noteDataId));
+ }
+ }
+
+ /**
+ * Parses and returns the specified external IDs.
+ *
+ * @param keys the keys of the external IDs
+ * @return the external IDs
+ */
+ public Set<ExternalId> get(Collection<ExternalId.Key> keys)
+ throws IOException, ConfigInvalidException {
+ checkLoaded();
+ HashSet<ExternalId> externalIds = Sets.newHashSetWithExpectedSize(keys.size());
+ for (ExternalId.Key key : keys) {
+ get(key).ifPresent(externalIds::add);
+ }
+ return externalIds;
+ }
+
+ /**
+ * Parses and returns all external IDs.
+ *
+ * <p>Invalid external IDs are ignored.
+ *
+ * @return all external IDs
+ */
+ public ImmutableSet<ExternalId> all() throws IOException {
+ checkLoaded();
+ try (RevWalk rw = new RevWalk(repo)) {
+ ImmutableSet.Builder<ExternalId> b = ImmutableSet.builder();
+ for (Note note : noteMap) {
+ byte[] raw = readNoteData(rw, note.getData());
+ try {
+ b.add(ExternalId.parse(note.getName(), raw, note.getData()));
+ } catch (ConfigInvalidException | RuntimeException e) {
+ logger.atSevere().withCause(e).log(
+ "Ignoring invalid external ID note %s", note.getName());
+ }
+ }
+ return b.build();
+ }
+ }
+
+ NoteMap getNoteMap() {
+ checkLoaded();
+ return noteMap;
+ }
+
+ static byte[] readNoteData(RevWalk rw, ObjectId noteDataId) throws IOException {
+ return rw.getObjectReader().open(noteDataId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+ }
+
+ /**
+ * Inserts a new external ID.
+ *
+ * @throws IOException on IO error while checking if external ID already exists
+ * @throws DuplicateExternalIdKeyException if the external ID already exists
+ */
+ public void insert(ExternalId extId) throws IOException, DuplicateExternalIdKeyException {
+ insert(Collections.singleton(extId));
+ }
+
+ /**
+ * Inserts new external IDs.
+ *
+ * @throws IOException on IO error while checking if external IDs already exist
+ * @throws DuplicateExternalIdKeyException if any of the external ID already exists
+ */
+ public void insert(Collection<ExternalId> extIds)
+ throws IOException, DuplicateExternalIdKeyException {
+ checkLoaded();
+ checkExternalIdsDontExist(extIds);
+
+ Set<ExternalId> newExtIds = new HashSet<>();
+ noteMapUpdates.add(
+ (rw, n, f) -> {
+ for (ExternalId extId : extIds) {
+ ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+ newExtIds.add(insertedExtId);
+ }
+ });
+ cacheUpdates.add(cu -> cu.add(newExtIds));
+ }
+
+ /**
+ * Inserts or updates an external ID.
+ *
+ * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
+ */
+ public void upsert(ExternalId extId) throws IOException, ConfigInvalidException {
+ upsert(Collections.singleton(extId));
+ }
+
+ /**
+ * Inserts or updates external IDs.
+ *
+ * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
+ */
+ public void upsert(Collection<ExternalId> extIds) throws IOException, ConfigInvalidException {
+ checkLoaded();
+ Set<ExternalId> removedExtIds = get(ExternalId.Key.from(extIds));
+ Set<ExternalId> updatedExtIds = new HashSet<>();
+ noteMapUpdates.add(
+ (rw, n, f) -> {
+ for (ExternalId extId : extIds) {
+ ExternalId updatedExtId = upsert(rw, inserter, noteMap, f, extId);
+ updatedExtIds.add(updatedExtId);
+ }
+ });
+ cacheUpdates.add(cu -> cu.remove(removedExtIds).add(updatedExtIds));
+ }
+
+ /**
+ * Deletes an external ID.
+ *
+ * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+ * key, but otherwise doesn't match the specified external ID.
+ */
+ public void delete(ExternalId extId) {
+ delete(Collections.singleton(extId));
+ }
+
+ /**
+ * Deletes external IDs.
+ *
+ * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+ * key as any of the external IDs that should be deleted, but otherwise doesn't match the that
+ * external ID.
+ */
+ public void delete(Collection<ExternalId> extIds) {
+ checkLoaded();
+ Set<ExternalId> removedExtIds = new HashSet<>();
+ noteMapUpdates.add(
+ (rw, n, f) -> {
+ for (ExternalId extId : extIds) {
+ remove(rw, noteMap, f, extId);
+ removedExtIds.add(extId);
+ }
+ });
+ cacheUpdates.add(cu -> cu.remove(removedExtIds));
+ }
+
+ /**
+ * Delete an external ID by key.
+ *
+ * @throws IllegalStateException is thrown if the external ID does not belong to the specified
+ * account.
+ */
+ public void delete(Account.Id accountId, ExternalId.Key extIdKey) {
+ delete(accountId, Collections.singleton(extIdKey));
+ }
+
+ /**
+ * Delete external IDs by external ID key.
+ *
+ * @throws IllegalStateException is thrown if any of the external IDs does not belong to the
+ * specified account.
+ */
+ public void delete(Account.Id accountId, Collection<ExternalId.Key> extIdKeys) {
+ checkLoaded();
+ Set<ExternalId> removedExtIds = new HashSet<>();
+ noteMapUpdates.add(
+ (rw, n, f) -> {
+ for (ExternalId.Key extIdKey : extIdKeys) {
+ ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, accountId);
+ removedExtIds.add(removedExtId);
+ }
+ });
+ cacheUpdates.add(cu -> cu.remove(removedExtIds));
+ }
+
+ /**
+ * Delete external IDs by external ID key.
+ *
+ * <p>The external IDs are deleted regardless of which account they belong to.
+ */
+ public void deleteByKeys(Collection<ExternalId.Key> extIdKeys) {
+ checkLoaded();
+ Set<ExternalId> removedExtIds = new HashSet<>();
+ noteMapUpdates.add(
+ (rw, n, f) -> {
+ for (ExternalId.Key extIdKey : extIdKeys) {
+ ExternalId extId = remove(rw, noteMap, f, extIdKey, null);
+ removedExtIds.add(extId);
+ }
+ });
+ cacheUpdates.add(cu -> cu.remove(removedExtIds));
+ }
+
+ /**
+ * Replaces external IDs for an account by external ID keys.
+ *
+ * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+ * external ID key is specified for deletion and an external ID with the same key is specified to
+ * be added, the old external ID with that key is deleted first and then the new external ID is
+ * added (so the external ID for that key is replaced).
+ *
+ * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to
+ * the specified account.
+ */
+ public void replace(
+ Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+ throws IOException, DuplicateExternalIdKeyException {
+ checkLoaded();
+ checkSameAccount(toAdd, accountId);
+ checkExternalIdKeysDontExist(ExternalId.Key.from(toAdd), toDelete);
+
+ Set<ExternalId> removedExtIds = new HashSet<>();
+ Set<ExternalId> updatedExtIds = new HashSet<>();
+ noteMapUpdates.add(
+ (rw, n, f) -> {
+ for (ExternalId.Key extIdKey : toDelete) {
+ ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, accountId);
+ if (removedExtId != null) {
+ removedExtIds.add(removedExtId);
+ }
+ }
+
+ for (ExternalId extId : toAdd) {
+ ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+ updatedExtIds.add(insertedExtId);
+ }
+ });
+ cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+ }
+
+ /**
+ * Replaces external IDs for an account by external ID keys.
+ *
+ * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+ * external ID key is specified for deletion and an external ID with the same key is specified to
+ * be added, the old external ID with that key is deleted first and then the new external ID is
+ * added (so the external ID for that key is replaced).
+ *
+ * <p>The external IDs are replaced regardless of which account they belong to.
+ */
+ public void replaceByKeys(Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+ throws IOException, DuplicateExternalIdKeyException {
+ checkLoaded();
+ checkExternalIdKeysDontExist(ExternalId.Key.from(toAdd), toDelete);
+
+ Set<ExternalId> removedExtIds = new HashSet<>();
+ Set<ExternalId> updatedExtIds = new HashSet<>();
+ noteMapUpdates.add(
+ (rw, n, f) -> {
+ for (ExternalId.Key extIdKey : toDelete) {
+ ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, null);
+ removedExtIds.add(removedExtId);
+ }
+
+ for (ExternalId extId : toAdd) {
+ ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+ updatedExtIds.add(insertedExtId);
+ }
+ });
+ cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+ }
+
+ /**
+ * Replaces an external ID.
+ *
+ * @throws IllegalStateException is thrown if the specified external IDs belong to different
+ * accounts.
+ */
+ public void replace(ExternalId toDelete, ExternalId toAdd)
+ throws IOException, DuplicateExternalIdKeyException {
+ replace(Collections.singleton(toDelete), Collections.singleton(toAdd));
+ }
+
+ /**
+ * Replaces external IDs.
+ *
+ * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+ * external ID is specified for deletion and an external ID with the same key is specified to be
+ * added, the old external ID with that key is deleted first and then the new external ID is added
+ * (so the external ID for that key is replaced).
+ *
+ * @throws IllegalStateException is thrown if the specified external IDs belong to different
+ * accounts.
+ */
+ public void replace(Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
+ throws IOException, DuplicateExternalIdKeyException {
+ Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+ if (accountId == null) {
+ // toDelete and toAdd are empty -> nothing to do
+ return;
+ }
+
+ replace(accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd);
+ }
+
+ @Override
+ protected void onLoad() throws IOException, ConfigInvalidException {
+ logger.atFine().log("Reading external ID note map");
+
+ noteMap = revision != null ? NoteMap.read(reader, revision) : NoteMap.newEmptyMap();
+
+ if (afterReadRevision != null) {
+ afterReadRevision.run();
+ }
+ }
+
+ @Override
+ public RevCommit commit(MetaDataUpdate update) throws IOException {
+ oldRev = revision != null ? revision.copy() : ObjectId.zeroId();
+ RevCommit commit = super.commit(update);
+ updateCount.increment();
+ return commit;
+ }
+
+ /**
+ * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
+ * external IDs were modified.
+ *
+ * <p>Must only be called after committing changes.
+ *
+ * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
+ *
+ * <p>No eviction from account cache and no reindex if this instance was created by {@link
+ * FactoryNoReindex}.
+ */
+ public void updateCaches() throws IOException {
+ updateCaches(ImmutableSet.of());
+ }
+
+ /**
+ * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
+ * external IDs were modified.
+ *
+ * <p>Must only be called after committing changes.
+ *
+ * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
+ *
+ * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
+ *
+ * @param accountsToSkip set of accounts that should not be evicted from the account cache, in
+ * this case the caller must take care to evict them otherwise
+ */
+ public void updateCaches(Collection<Account.Id> accountsToSkip) throws IOException {
+ checkState(oldRev != null, "no changes committed yet");
+
+ ExternalIdCacheUpdates externalIdCacheUpdates = new ExternalIdCacheUpdates();
+ for (CacheUpdate cacheUpdate : cacheUpdates) {
+ cacheUpdate.execute(externalIdCacheUpdates);
+ }
+
+ externalIdCache.onReplace(
+ oldRev,
+ getRevision(),
+ externalIdCacheUpdates.getRemoved(),
+ externalIdCacheUpdates.getAdded());
+
+ if (accountCache != null || accountIndexer != null) {
+ for (Account.Id id :
+ Streams.concat(
+ externalIdCacheUpdates.getAdded().stream(),
+ externalIdCacheUpdates.getRemoved().stream())
+ .map(ExternalId::accountId)
+ .filter(i -> !accountsToSkip.contains(i))
+ .collect(toSet())) {
+ if (accountCache != null) {
+ accountCache.evict(id);
+ }
+ if (accountIndexer != null) {
+ accountIndexer.get().index(id);
+ }
+ }
+ }
+
+ cacheUpdates.clear();
+ oldRev = null;
+ }
+
+ @Override
+ protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+ checkState(!readOnly, "Updating external IDs is disabled");
+
+ if (noteMapUpdates.isEmpty()) {
+ return false;
+ }
+
+ logger.atFine().log("Updating external IDs");
+
+ if (Strings.isNullOrEmpty(commit.getMessage())) {
+ commit.setMessage("Update external IDs\n");
+ }
+
+ try (RevWalk rw = new RevWalk(reader)) {
+ Set<String> footers = new HashSet<>();
+ for (NoteMapUpdate noteMapUpdate : noteMapUpdates) {
+ try {
+ noteMapUpdate.execute(rw, noteMap, footers);
+ } catch (DuplicateExternalIdKeyException e) {
+ throw new IOException(e);
+ }
+ }
+ noteMapUpdates.clear();
+ if (!footers.isEmpty()) {
+ commit.setMessage(
+ footers.stream()
+ .sorted()
+ .collect(joining("\n", commit.getMessage().trim() + "\n\n", "")));
+ }
+
+ RevTree oldTree = revision != null ? rw.parseTree(revision) : null;
+ ObjectId newTreeId = noteMap.writeTree(inserter);
+ if (newTreeId.equals(oldTree)) {
+ return false;
+ }
+
+ commit.setTreeId(newTreeId);
+ return true;
+ }
+ }
+
+ /**
+ * Checks that all specified external IDs belong to the same account.
+ *
+ * @return the ID of the account to which all specified external IDs belong.
+ */
+ private static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
+ return checkSameAccount(extIds, null);
+ }
+
+ /**
+ * Checks that all specified external IDs belong to specified account. If no account is specified
+ * it is checked that all specified external IDs belong to the same account.
+ *
+ * @return the ID of the account to which all specified external IDs belong.
+ */
+ public static Account.Id checkSameAccount(
+ Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
+ for (ExternalId extId : extIds) {
+ if (accountId == null) {
+ accountId = extId.accountId();
+ continue;
+ }
+ checkState(
+ accountId.equals(extId.accountId()),
+ "external id %s belongs to account %s, expected account %s",
+ extId.key().get(),
+ extId.accountId().get(),
+ accountId.get());
+ }
+ return accountId;
+ }
+
+ /**
+ * Insert or updates an new external ID and sets it in the note map.
+ *
+ * <p>If the external ID already exists it is overwritten.
+ */
+ private static ExternalId upsert(
+ RevWalk rw, ObjectInserter ins, NoteMap noteMap, Set<String> footers, ExternalId extId)
+ throws IOException, ConfigInvalidException {
+ ObjectId noteId = extId.key().sha1();
+ Config c = new Config();
+ if (noteMap.contains(extId.key().sha1())) {
+ ObjectId noteDataId = noteMap.get(noteId);
+ byte[] raw = readNoteData(rw, noteDataId);
+ try {
+ c = new BlobBasedConfig(null, raw);
+ ExternalId oldExtId = ExternalId.parse(noteId.name(), c, noteDataId);
+ addFooters(footers, oldExtId);
+ } catch (ConfigInvalidException e) {
+ throw new ConfigInvalidException(
+ String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
+ }
+ }
+ extId.writeToConfig(c);
+ byte[] raw = c.toText().getBytes(UTF_8);
+ ObjectId noteData = ins.insert(OBJ_BLOB, raw);
+ noteMap.set(noteId, noteData);
+ ExternalId newExtId = ExternalId.create(extId, noteData);
+ addFooters(footers, newExtId);
+ return newExtId;
+ }
+
+ /**
+ * Removes an external ID from the note map.
+ *
+ * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+ * key, but otherwise doesn't match the specified external ID.
+ */
+ private static ExternalId remove(
+ RevWalk rw, NoteMap noteMap, Set<String> footers, ExternalId extId)
+ throws IOException, ConfigInvalidException {
+ ObjectId noteId = extId.key().sha1();
+ if (!noteMap.contains(noteId)) {
+ return null;
+ }
+
+ ObjectId noteDataId = noteMap.get(noteId);
+ byte[] raw = readNoteData(rw, noteDataId);
+ ExternalId actualExtId = ExternalId.parse(noteId.name(), raw, noteDataId);
+ checkState(
+ extId.equals(actualExtId),
+ "external id %s should be removed, but it's not matching the actual external id %s",
+ extId.toString(),
+ actualExtId.toString());
+ noteMap.remove(noteId);
+ addFooters(footers, actualExtId);
+ return actualExtId;
+ }
+
+ /**
+ * Removes an external ID from the note map by external ID key.
+ *
+ * @throws IllegalStateException is thrown if an expected account ID is provided and an external
+ * ID with the specified key exists, but belongs to another account.
+ * @return the external ID that was removed, {@code null} if no external ID with the specified key
+ * exists
+ */
+ private static ExternalId remove(
+ RevWalk rw,
+ NoteMap noteMap,
+ Set<String> footers,
+ ExternalId.Key extIdKey,
+ Account.Id expectedAccountId)
+ throws IOException, ConfigInvalidException {
+ ObjectId noteId = extIdKey.sha1();
+ if (!noteMap.contains(noteId)) {
+ return null;
+ }
+
+ ObjectId noteDataId = noteMap.get(noteId);
+ byte[] raw = readNoteData(rw, noteDataId);
+ ExternalId extId = ExternalId.parse(noteId.name(), raw, noteDataId);
+ if (expectedAccountId != null) {
+ checkState(
+ expectedAccountId.equals(extId.accountId()),
+ "external id %s should be removed for account %s,"
+ + " but external id belongs to account %s",
+ extIdKey.get(),
+ expectedAccountId.get(),
+ extId.accountId().get());
+ }
+ noteMap.remove(noteId);
+ addFooters(footers, extId);
+ return extId;
+ }
+
+ private static void addFooters(Set<String> footers, ExternalId extId) {
+ footers.add("Account: " + extId.accountId().get());
+ if (extId.email() != null) {
+ footers.add("Email: " + extId.email());
+ }
+ }
+
+ private void checkExternalIdsDontExist(Collection<ExternalId> extIds)
+ throws DuplicateExternalIdKeyException, IOException {
+ checkExternalIdKeysDontExist(ExternalId.Key.from(extIds));
+ }
+
+ private void checkExternalIdKeysDontExist(
+ Collection<ExternalId.Key> extIdKeysToAdd, Collection<ExternalId.Key> extIdKeysToDelete)
+ throws DuplicateExternalIdKeyException, IOException {
+ HashSet<ExternalId.Key> newKeys = new HashSet<>(extIdKeysToAdd);
+ newKeys.removeAll(extIdKeysToDelete);
+ checkExternalIdKeysDontExist(newKeys);
+ }
+
+ private void checkExternalIdKeysDontExist(Collection<ExternalId.Key> extIdKeys)
+ throws IOException, DuplicateExternalIdKeyException {
+ for (ExternalId.Key extIdKey : extIdKeys) {
+ if (noteMap.contains(extIdKey.sha1())) {
+ throw new DuplicateExternalIdKeyException(extIdKey);
+ }
+ }
+ }
+
+ private void checkLoaded() {
+ checkState(noteMap != null, "External IDs not loaded yet");
+ }
+
+ @FunctionalInterface
+ private interface NoteMapUpdate {
+ void execute(RevWalk rw, NoteMap noteMap, Set<String> footers)
+ throws IOException, ConfigInvalidException, DuplicateExternalIdKeyException;
+ }
+
+ @FunctionalInterface
+ private interface CacheUpdate {
+ void execute(ExternalIdCacheUpdates cacheUpdates) throws IOException;
+ }
+
+ private static class ExternalIdCacheUpdates {
+ private final Set<ExternalId> added = new HashSet<>();
+ private final Set<ExternalId> removed = new HashSet<>();
+
+ ExternalIdCacheUpdates add(Collection<ExternalId> extIds) {
+ this.added.addAll(extIds);
+ return this;
+ }
+
+ public Set<ExternalId> getAdded() {
+ return ImmutableSet.copyOf(added);
+ }
+
+ ExternalIdCacheUpdates remove(Collection<ExternalId> extIds) {
+ this.removed.addAll(extIds);
+ return this;
+ }
+
+ public Set<ExternalId> getRemoved() {
+ return ImmutableSet.copyOf(removed);
+ }
+ }
+}