| // 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.entities.Account; |
| import com.google.gerrit.entities.RefNames; |
| import com.google.gerrit.git.ObjectIds; |
| 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.server.account.AccountCache; |
| import com.google.gerrit.server.account.AccountsUpdate; |
| 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.gerrit.server.logging.CallerFinder; |
| import com.google.gerrit.server.update.RetryHelper; |
| 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 final CallerFinder callerFinder; |
| |
| 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"); |
| this.callerFinder = |
| CallerFinder.builder() |
| // 1. callers that come through ExternalIds |
| .addTarget(ExternalIds.class) |
| |
| // 2. callers that come through AccountsUpdate |
| .addTarget(AccountsUpdate.class) |
| .addIgnoredPackage("com.github.rholder.retry") |
| .addIgnoredClass(RetryHelper.class) |
| |
| // 3. direct callers |
| .addTarget(ExternalIdNotes.class) |
| .build(); |
| } |
| |
| 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 { |
| if (revision != null) { |
| logger.atFine().log( |
| "Reading external ID note map (caller: %s)", callerFinder.findCallerLazy()); |
| noteMap = NoteMap.read(reader, revision); |
| } else { |
| noteMap = NoteMap.newEmptyMap(); |
| } |
| |
| if (afterReadRevision != null) { |
| afterReadRevision.run(); |
| } |
| } |
| |
| @Override |
| public RevCommit commit(MetaDataUpdate update) throws IOException { |
| oldRev = ObjectIds.copyOrZero(revision); |
| 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, but 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 doesn't match 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); |
| } |
| } |
| } |