| // 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.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.extensions.registration.DynamicMap; |
| 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.AccountsUpdate; |
| import com.google.gerrit.server.config.AllUsersName; |
| import com.google.gerrit.server.config.AuthConfig; |
| 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 java.util.function.Function; |
| 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 |
| * ExternalIdNotesLoader#updateExternalIdCacheAndMaybeReindexAccounts(ExternalIdNotes, |
| * Collection)}). |
| */ |
| public class ExternalIdNotes extends VersionedMetaData { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private static final int MAX_NOTE_SZ = 1 << 19; |
| |
| public abstract static class ExternalIdNotesLoader { |
| protected final ExternalIdCache externalIdCache; |
| protected final MetricMaker metricMaker; |
| protected final AllUsersName allUsersName; |
| protected final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors; |
| protected final ExternalIdFactory externalIdFactory; |
| protected final AuthConfig authConfig; |
| |
| protected ExternalIdNotesLoader( |
| ExternalIdCache externalIdCache, |
| MetricMaker metricMaker, |
| AllUsersName allUsersName, |
| DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors, |
| ExternalIdFactory externalIdFactory, |
| AuthConfig authConfig) { |
| this.externalIdCache = externalIdCache; |
| this.metricMaker = metricMaker; |
| this.allUsersName = allUsersName; |
| this.upsertPreprocessors = upsertPreprocessors; |
| this.externalIdFactory = externalIdFactory; |
| this.authConfig = authConfig; |
| } |
| |
| /** |
| * Loads the external ID notes from the current tip of the {@code refs/meta/external-ids} |
| * branch. |
| * |
| * @param allUsersRepo the All-Users repository |
| */ |
| public abstract 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 |
| */ |
| public abstract ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev) |
| throws IOException, ConfigInvalidException; |
| |
| /** |
| * Updates the external ID cache. Subclasses of type {@link Factory} will also reindex the |
| * accounts for which external IDs were modified, while subclasses of type {@link |
| * FactoryNoReindex} will skip this. |
| * |
| * <p>Must only be called after committing changes. |
| * |
| * @param externalIdNotes the committed updates that should be applied to the cache. This first |
| * and last element must be the updates commited first and last, respectively. |
| * @param accountsToSkipForReindex accounts that should not be reindexed. This is to avoid |
| * double reindexing when updated accounts will already be reindexed by |
| * ReindexAfterRefUpdate. |
| */ |
| public void updateExternalIdCacheAndMaybeReindexAccounts( |
| ExternalIdNotes externalIdNotes, Collection<Account.Id> accountsToSkipForReindex) |
| throws IOException { |
| checkState(externalIdNotes.oldRev != null, "no changes committed yet"); |
| |
| // readOnly is ignored here (legacy behavior). |
| |
| // Aggregate all updates. |
| ExternalIdCacheUpdates updates = new ExternalIdCacheUpdates(); |
| for (CacheUpdate cacheUpdate : externalIdNotes.cacheUpdates) { |
| cacheUpdate.execute(updates); |
| } |
| |
| // Reindex accounts (if the subclass implements reindexAccount()). |
| if (!externalIdNotes.noReindex) { |
| Streams.concat(updates.getAdded().stream(), updates.getRemoved().stream()) |
| .map(ExternalId::accountId) |
| .filter(i -> !accountsToSkipForReindex.contains(i)) |
| .distinct() |
| .forEach(this::reindexAccount); |
| } |
| |
| // Reset instance state. |
| externalIdNotes.cacheUpdates.clear(); |
| externalIdNotes.keysToAdd.clear(); |
| externalIdNotes.oldRev = null; |
| } |
| |
| protected abstract void reindexAccount(Account.Id id); |
| } |
| |
| @Singleton |
| public static class Factory extends ExternalIdNotesLoader { |
| |
| private final Provider<AccountIndexer> accountIndexer; |
| |
| @Inject |
| Factory( |
| ExternalIdCache externalIdCache, |
| Provider<AccountIndexer> accountIndexer, |
| MetricMaker metricMaker, |
| AllUsersName allUsersName, |
| DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors, |
| ExternalIdFactory externalIdFactory, |
| AuthConfig authConfig) { |
| super( |
| externalIdCache, |
| metricMaker, |
| allUsersName, |
| upsertPreprocessors, |
| externalIdFactory, |
| authConfig); |
| this.accountIndexer = accountIndexer; |
| } |
| |
| @Override |
| public ExternalIdNotes load(Repository allUsersRepo) |
| throws IOException, ConfigInvalidException { |
| return new ExternalIdNotes( |
| metricMaker, |
| allUsersName, |
| allUsersRepo, |
| upsertPreprocessors, |
| externalIdFactory, |
| authConfig.isUserNameCaseInsensitiveMigrationMode()) |
| .load(); |
| } |
| |
| @Override |
| public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev) |
| throws IOException, ConfigInvalidException { |
| return new ExternalIdNotes( |
| metricMaker, |
| allUsersName, |
| allUsersRepo, |
| upsertPreprocessors, |
| externalIdFactory, |
| authConfig.isUserNameCaseInsensitiveMigrationMode()) |
| .load(rev); |
| } |
| |
| @Override |
| protected void reindexAccount(Account.Id id) { |
| accountIndexer.get().index(id); |
| } |
| } |
| |
| @Singleton |
| public static class FactoryNoReindex extends ExternalIdNotesLoader { |
| |
| @Inject |
| FactoryNoReindex( |
| ExternalIdCache externalIdCache, |
| MetricMaker metricMaker, |
| AllUsersName allUsersName, |
| DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors, |
| ExternalIdFactory externalIdFactory, |
| AuthConfig authConfig) { |
| super( |
| externalIdCache, |
| metricMaker, |
| allUsersName, |
| upsertPreprocessors, |
| externalIdFactory, |
| authConfig); |
| } |
| |
| @Override |
| public ExternalIdNotes load(Repository allUsersRepo) |
| throws IOException, ConfigInvalidException { |
| return new ExternalIdNotes( |
| metricMaker, |
| allUsersName, |
| allUsersRepo, |
| upsertPreprocessors, |
| externalIdFactory, |
| authConfig.isUserNameCaseInsensitiveMigrationMode()) |
| .setNoReindex() |
| .load(); |
| } |
| |
| @Override |
| public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev) |
| throws IOException, ConfigInvalidException { |
| return new ExternalIdNotes( |
| metricMaker, |
| allUsersName, |
| allUsersRepo, |
| upsertPreprocessors, |
| externalIdFactory, |
| authConfig.isUserNameCaseInsensitiveMigrationMode()) |
| .setNoReindex() |
| .load(rev); |
| } |
| |
| @Override |
| protected void reindexAccount(Account.Id id) { |
| // Do not reindex. |
| } |
| } |
| |
| /** |
| * 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, |
| ExternalIdFactory externalIdFactory, |
| boolean isUserNameCaseInsensitiveMigrationMode) |
| throws IOException, ConfigInvalidException { |
| return new ExternalIdNotes( |
| new DisabledMetricMaker(), |
| allUsersName, |
| allUsersRepo, |
| DynamicMap.emptyMap(), |
| externalIdFactory, |
| isUserNameCaseInsensitiveMigrationMode) |
| .setReadOnly() |
| .setNoReindex() |
| .load(rev); |
| } |
| |
| /** |
| * Loads the external ID notes for updates. 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 load( |
| AllUsersName allUsersName, |
| Repository allUsersRepo, |
| ExternalIdFactory externalIdFactory, |
| boolean isUserNameCaseInsensitiveMigrationMode) |
| throws IOException, ConfigInvalidException { |
| return new ExternalIdNotes( |
| new DisabledMetricMaker(), |
| allUsersName, |
| allUsersRepo, |
| DynamicMap.emptyMap(), |
| externalIdFactory, |
| isUserNameCaseInsensitiveMigrationMode) |
| .setNoReindex() |
| .load(); |
| } |
| |
| private final AllUsersName allUsersName; |
| private final Counter0 updateCount; |
| private final Repository repo; |
| private final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors; |
| private final CallerFinder callerFinder; |
| private final ExternalIdFactory externalIdFactory; |
| |
| private NoteMap noteMap; |
| private ObjectId oldRev; |
| |
| /** Staged note map updates that should be executed on save. */ |
| private final List<NoteMapUpdate> noteMapUpdates = new ArrayList<>(); |
| |
| /** Staged cache updates that should be executed after external ID changes have been committed. */ |
| private final List<CacheUpdate> cacheUpdates = new ArrayList<>(); |
| |
| /** |
| * When performing batch updates (cf. {@link AccountsUpdate#updateBatch(List)} we need to ensure |
| * the batch does not introduce duplicates. In addition to checking against the status quo in |
| * {@link #noteMap} (cf. {@link #checkExternalIdKeysDontExist(Collection)}), which is sufficient |
| * for single updates, we also need to check for duplicates among the batch updates. As the actual |
| * updates are computed lazily just before applying them, we unfortunately need to track keys |
| * explicitly here even though they are already implicit in the lambdas that constitute the |
| * updates. |
| */ |
| private final Set<ExternalId.Key> keysToAdd = new HashSet<>(); |
| |
| private Runnable afterReadRevision; |
| private boolean readOnly = false; |
| private boolean noReindex = false; |
| private boolean isUserNameCaseInsensitiveMigrationMode = false; |
| protected final Function<ExternalId, ObjectId> defaultNoteIdResolver = |
| (extId) -> { |
| ObjectId noteId = extId.key().sha1(); |
| try { |
| if (isUserNameCaseInsensitiveMigrationMode && !noteMap.contains(noteId)) { |
| noteId = extId.key().caseSensitiveSha1(); |
| } |
| } catch (IOException e) { |
| return noteId; |
| } |
| return noteId; |
| }; |
| |
| private ExternalIdNotes( |
| MetricMaker metricMaker, |
| AllUsersName allUsersName, |
| Repository allUsersRepo, |
| DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors, |
| ExternalIdFactory externalIdFactory, |
| boolean isUserNameCaseInsensitiveMigrationMode) { |
| 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.upsertPreprocessors = upsertPreprocessors; |
| 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(); |
| this.externalIdFactory = externalIdFactory; |
| this.isUserNameCaseInsensitiveMigrationMode = isUserNameCaseInsensitiveMigrationMode; |
| } |
| |
| public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) { |
| this.afterReadRevision = afterReadRevision; |
| return this; |
| } |
| |
| private ExternalIdNotes setReadOnly() { |
| readOnly = true; |
| return this; |
| } |
| |
| private ExternalIdNotes setNoReindex() { |
| noReindex = 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 = getNoteId(key); |
| if (noteMap.contains(noteId)) { |
| |
| try (RevWalk rw = new RevWalk(repo)) { |
| ObjectId noteDataId = noteMap.get(noteId); |
| byte[] raw = readNoteData(rw, noteDataId); |
| return Optional.of(externalIdFactory.parse(noteId.name(), raw, noteDataId)); |
| } |
| } |
| return Optional.empty(); |
| } |
| |
| protected ObjectId getNoteId(ExternalId.Key key) throws IOException { |
| ObjectId noteId = key.sha1(); |
| |
| if (!noteMap.contains(noteId) && isUserNameCaseInsensitiveMigrationMode) { |
| noteId = key.caseSensitiveSha1(); |
| } |
| |
| return noteId; |
| } |
| |
| /** |
| * 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(externalIdFactory.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) -> { |
| for (ExternalId extId : extIds) { |
| ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId); |
| preprocessUpsert(insertedExtId); |
| newExtIds.add(insertedExtId); |
| } |
| }); |
| cacheUpdates.add(cu -> cu.add(newExtIds)); |
| incrementalDuplicateDetection(extIds); |
| } |
| |
| /** |
| * 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) -> { |
| for (ExternalId extId : extIds) { |
| ExternalId updatedExtId = upsert(rw, inserter, noteMap, extId); |
| preprocessUpsert(updatedExtId); |
| updatedExtIds.add(updatedExtId); |
| } |
| }); |
| cacheUpdates.add(cu -> cu.remove(removedExtIds).add(updatedExtIds)); |
| incrementalDuplicateDetection(extIds); |
| } |
| |
| /** |
| * 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) -> { |
| for (ExternalId extId : extIds) { |
| remove(rw, noteMap, 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) -> { |
| for (ExternalId.Key extIdKey : extIdKeys) { |
| ExternalId removedExtId = remove(rw, noteMap, 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) -> { |
| for (ExternalId.Key extIdKey : extIdKeys) { |
| ExternalId extId = remove(rw, noteMap, extIdKey, null); |
| removedExtIds.add(extId); |
| } |
| }); |
| cacheUpdates.add(cu -> cu.remove(removedExtIds)); |
| } |
| |
| public void replace( |
| Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd) |
| throws IOException, DuplicateExternalIdKeyException { |
| replace(accountId, toDelete, toAdd, defaultNoteIdResolver); |
| } |
| |
| /** |
| * 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, |
| Function<ExternalId, ObjectId> noteIdResolver) |
| 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) -> { |
| for (ExternalId.Key extIdKey : toDelete) { |
| ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId); |
| if (removedExtId != null) { |
| removedExtIds.add(removedExtId); |
| } |
| } |
| |
| for (ExternalId extId : toAdd) { |
| ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId, noteIdResolver); |
| preprocessUpsert(insertedExtId); |
| updatedExtIds.add(insertedExtId); |
| } |
| }); |
| cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds)); |
| incrementalDuplicateDetection(toAdd); |
| } |
| |
| /** |
| * 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) -> { |
| for (ExternalId.Key extIdKey : toDelete) { |
| ExternalId removedExtId = remove(rw, noteMap, extIdKey, null); |
| removedExtIds.add(removedExtId); |
| } |
| |
| for (ExternalId extId : toAdd) { |
| ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId); |
| preprocessUpsert(insertedExtId); |
| updatedExtIds.add(insertedExtId); |
| } |
| }); |
| cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds)); |
| incrementalDuplicateDetection(toAdd); |
| } |
| |
| /** |
| * 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); |
| } |
| |
| /** |
| * 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, |
| Function<ExternalId, ObjectId> noteIdResolver) |
| 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, noteIdResolver); |
| } |
| |
| @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; |
| } |
| |
| @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)) { |
| for (NoteMapUpdate noteMapUpdate : noteMapUpdates) { |
| try { |
| noteMapUpdate.execute(rw, noteMap); |
| } catch (DuplicateExternalIdKeyException e) { |
| throw new IOException(e); |
| } |
| } |
| noteMapUpdates.clear(); |
| |
| 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; |
| } |
| |
| private void incrementalDuplicateDetection(Collection<ExternalId> externalIds) { |
| externalIds.stream() |
| .map(ExternalId::key) |
| .forEach( |
| key -> { |
| if (!keysToAdd.add(key)) { |
| throw new DuplicateExternalIdKeyException(key); |
| } |
| }); |
| } |
| |
| /** |
| * Inserts or updates a new external ID and sets it in the note map. |
| * |
| * <p>If the external ID already exists, it is overwritten. |
| */ |
| private ExternalId upsert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId) |
| throws IOException, ConfigInvalidException { |
| return upsert(rw, ins, noteMap, extId, defaultNoteIdResolver); |
| } |
| |
| /** |
| * Inserts or updates a new external ID and sets it in the note map. |
| * |
| * <p>If the external ID already exists, it is overwritten. |
| */ |
| private ExternalId upsert( |
| RevWalk rw, |
| ObjectInserter ins, |
| NoteMap noteMap, |
| ExternalId extId, |
| Function<ExternalId, ObjectId> noteIdResolver) |
| throws IOException, ConfigInvalidException { |
| ObjectId noteId = extId.key().sha1(); |
| Config c = new Config(); |
| ObjectId resolvedNoteId = noteIdResolver.apply(extId); |
| if (noteMap.contains(resolvedNoteId)) { |
| noteId = resolvedNoteId; |
| ObjectId noteDataId = noteMap.get(noteId); |
| byte[] raw = readNoteData(rw, noteDataId); |
| try { |
| c = new BlobBasedConfig(null, raw); |
| } 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); |
| return externalIdFactory.create(extId, noteData); |
| } |
| |
| /** |
| * 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 void remove(RevWalk rw, NoteMap noteMap, ExternalId extId) |
| throws IOException, ConfigInvalidException { |
| ObjectId noteId = getNoteId(extId.key()); |
| |
| if (!noteMap.contains(noteId)) { |
| return; |
| } |
| |
| ObjectId noteDataId = noteMap.get(noteId); |
| byte[] raw = readNoteData(rw, noteDataId); |
| ExternalId actualExtId = externalIdFactory.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); |
| } |
| |
| /** |
| * 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 ExternalId remove( |
| RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId) |
| throws IOException, ConfigInvalidException { |
| ObjectId noteId = getNoteId(extIdKey); |
| |
| if (!noteMap.contains(noteId)) { |
| return null; |
| } |
| |
| ObjectId noteDataId = noteMap.get(noteId); |
| byte[] raw = readNoteData(rw, noteDataId); |
| ExternalId extId = externalIdFactory.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); |
| return extId; |
| } |
| |
| 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"); |
| } |
| |
| private void preprocessUpsert(ExternalId extId) { |
| upsertPreprocessors.forEach(p -> p.get().upsert(extId)); |
| } |
| |
| @FunctionalInterface |
| private interface NoteMapUpdate { |
| void execute(RevWalk rw, NoteMap noteMap) |
| throws IOException, ConfigInvalidException, DuplicateExternalIdKeyException; |
| } |
| |
| @FunctionalInterface |
| private interface CacheUpdate { |
| void execute(ExternalIdCacheUpdates cacheUpdates) throws IOException; |
| } |
| |
| private static class ExternalIdCacheUpdates { |
| final Set<ExternalId> added = new HashSet<>(); |
| final Set<ExternalId> removed = new HashSet<>(); |
| |
| ExternalIdCacheUpdates add(Collection<ExternalId> extIds) { |
| this.added.addAll(extIds); |
| return this; |
| } |
| |
| Set<ExternalId> getAdded() { |
| return ImmutableSet.copyOf(added); |
| } |
| |
| ExternalIdCacheUpdates remove(Collection<ExternalId> extIds) { |
| this.removed.addAll(extIds); |
| return this; |
| } |
| |
| Set<ExternalId> getRemoved() { |
| return ImmutableSet.copyOf(removed); |
| } |
| } |
| } |