blob: e6b82e67722c74fd0585ac10184c49188a811f1f [file] [log] [blame]
// Copyright (C) 2016 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.notedb;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
import static java.util.Objects.requireNonNull;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.primitives.Longs;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
import com.google.gerrit.server.git.RefCache;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gwtorm.server.OrmRuntimeException;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
/**
* The state of all relevant NoteDb refs across all repos corresponding to a given Change entity.
*
* <p>Stored serialized in the {@code Change#noteDbState} field, and used to determine whether the
* state in NoteDb is out of date.
*
* <p>Serialized in one of the forms:
*
* <ul>
* <li>[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
* <li>R,[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
* <li>R=[read-only-until],[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
* <li>N
* <li>N=[read-only-until]
* </ul>
*
* in numeric account ID order, with hex SHA-1s for human readability.
*/
public class NoteDbChangeState {
public static final String NOTE_DB_PRIMARY_STATE = "N";
public enum PrimaryStorage {
REVIEW_DB('R'),
NOTE_DB('N');
private final char code;
PrimaryStorage(char code) {
this.code = code;
}
public static PrimaryStorage of(@Nullable Change c) {
return of(NoteDbChangeState.parse(c));
}
public static PrimaryStorage of(@Nullable NoteDbChangeState s) {
return s != null ? s.getPrimaryStorage() : REVIEW_DB;
}
}
@AutoValue
public abstract static class Delta {
@VisibleForTesting
public static Delta create(
Change.Id changeId,
Optional<ObjectId> newChangeMetaId,
Map<Account.Id, ObjectId> newDraftIds) {
if (newDraftIds == null) {
newDraftIds = ImmutableMap.of();
}
return new AutoValue_NoteDbChangeState_Delta(
changeId, newChangeMetaId, ImmutableMap.copyOf(newDraftIds));
}
abstract Change.Id changeId();
abstract Optional<ObjectId> newChangeMetaId();
abstract ImmutableMap<Account.Id, ObjectId> newDraftIds();
}
@AutoValue
public abstract static class RefState {
@VisibleForTesting
public static RefState create(ObjectId changeMetaId, Map<Account.Id, ObjectId> draftIds) {
return new AutoValue_NoteDbChangeState_RefState(
changeMetaId.copy(),
ImmutableMap.copyOf(Maps.filterValues(draftIds, id -> !ObjectId.zeroId().equals(id))));
}
private static Optional<RefState> parse(Change.Id changeId, List<String> parts) {
checkArgument(!parts.isEmpty(), "missing state string for change %s", changeId);
ObjectId changeMetaId = ObjectId.fromString(parts.get(0));
Map<Account.Id, ObjectId> draftIds = Maps.newHashMapWithExpectedSize(parts.size() - 1);
Splitter s = Splitter.on('=');
for (int i = 1; i < parts.size(); i++) {
String p = parts.get(i);
List<String> draftParts = s.splitToList(p);
checkArgument(
draftParts.size() == 2, "invalid draft state part for change %s: %s", changeId, p);
Optional<Account.Id> accountId = Account.Id.tryParse(draftParts.get(0));
checkArgument(
accountId.isPresent(),
"invalid account ID in draft state part for change %s: %s",
changeId,
p);
draftIds.put(accountId.get(), ObjectId.fromString(draftParts.get(1)));
}
return Optional.of(create(changeMetaId, draftIds));
}
abstract ObjectId changeMetaId();
abstract ImmutableMap<Account.Id, ObjectId> draftIds();
@Override
public String toString() {
return appendTo(new StringBuilder()).toString();
}
StringBuilder appendTo(StringBuilder sb) {
sb.append(changeMetaId().name());
for (Account.Id id : ReviewDbUtil.intKeyOrdering().sortedCopy(draftIds().keySet())) {
sb.append(',').append(id.get()).append('=').append(draftIds().get(id).name());
}
return sb;
}
}
public static NoteDbChangeState parse(@Nullable Change c) {
return c != null ? parse(c.getId(), c.getNoteDbState()) : null;
}
@VisibleForTesting
public static NoteDbChangeState parse(Change.Id id, @Nullable String str) {
if (Strings.isNullOrEmpty(str)) {
// Return null rather than Optional as this is what goes in the field in
// ReviewDb.
return null;
}
List<String> parts = Splitter.on(',').splitToList(str);
String first = parts.get(0);
Optional<Timestamp> readOnlyUntil = parseReadOnlyUntil(id, str, first);
// Only valid NOTE_DB state is "N".
if (parts.size() == 1 && first.charAt(0) == NOTE_DB.code) {
return new NoteDbChangeState(id, NOTE_DB, Optional.empty(), readOnlyUntil);
}
// Otherwise it must be REVIEW_DB, either "R,<RefState>" or just
// "<RefState>". Allow length > 0 for forward compatibility.
if (first.length() > 0) {
Optional<RefState> refState;
if (first.charAt(0) == REVIEW_DB.code) {
refState = RefState.parse(id, parts.subList(1, parts.size()));
} else {
refState = RefState.parse(id, parts);
}
return new NoteDbChangeState(id, REVIEW_DB, refState, readOnlyUntil);
}
throw invalidState(id, str);
}
private static Optional<Timestamp> parseReadOnlyUntil(
Change.Id id, String fullStr, String first) {
if (first.length() > 2 && first.charAt(1) == '=') {
Long ts = Longs.tryParse(first.substring(2));
if (ts == null) {
throw invalidState(id, fullStr);
}
return Optional.of(new Timestamp(ts));
}
return Optional.empty();
}
private static IllegalArgumentException invalidState(Change.Id id, String str) {
return new IllegalArgumentException("invalid state string for change " + id + ": " + str);
}
/**
* Apply a delta to the state stored in a change entity.
*
* <p>This method does not check whether the old state was read-only; it is up to the caller to
* not violate read-only semantics when storing the change back in ReviewDb.
*
* @param change change entity. The delta is applied against this entity's {@code noteDbState} and
* the new state is stored back in the entity as a side effect.
* @param delta delta to apply.
* @return new state, equivalent to what is stored in {@code change} as a side effect.
*/
public static NoteDbChangeState applyDelta(Change change, Delta delta) {
if (delta == null) {
return null;
}
String oldStr = change.getNoteDbState();
if (oldStr == null && !delta.newChangeMetaId().isPresent()) {
// Neither an old nor a new meta ID was present, most likely because we
// aren't writing a NoteDb graph at all for this change at this point. No
// point in proceeding.
return null;
}
NoteDbChangeState oldState = parse(change.getId(), oldStr);
if (oldState != null && oldState.getPrimaryStorage() == NOTE_DB) {
// NOTE_DB state doesn't include RefState, so applying a delta is a no-op.
return oldState;
}
ObjectId changeMetaId;
if (delta.newChangeMetaId().isPresent()) {
changeMetaId = delta.newChangeMetaId().get();
if (changeMetaId.equals(ObjectId.zeroId())) {
change.setNoteDbState(null);
return null;
}
} else {
changeMetaId = oldState.getChangeMetaId();
}
Map<Account.Id, ObjectId> draftIds = new HashMap<>();
if (oldState != null) {
draftIds.putAll(oldState.getDraftIds());
}
for (Map.Entry<Account.Id, ObjectId> e : delta.newDraftIds().entrySet()) {
if (e.getValue().equals(ObjectId.zeroId())) {
draftIds.remove(e.getKey());
} else {
draftIds.put(e.getKey(), e.getValue());
}
}
NoteDbChangeState state =
new NoteDbChangeState(
change.getId(),
oldState != null ? oldState.getPrimaryStorage() : REVIEW_DB,
Optional.of(RefState.create(changeMetaId, draftIds)),
// Copy old read-only deadline rather than advancing it; the caller is
// still responsible for finishing the rest of its work before the lease
// runs out.
oldState != null ? oldState.getReadOnlyUntil() : Optional.empty());
change.setNoteDbState(state.toString());
return state;
}
// TODO(dborowitz): Ugly. Refactor these static methods into a Checker class
// or something. They do not belong in NoteDbChangeState itself because:
// - need to inject Config but don't want a whole Factory
// - can't be methods on NoteDbChangeState because state is nullable (though
// we could also solve this by inventing an empty-but-non-null state)
// Also we should clean up duplicated code between static/non-static methods.
public static boolean isChangeUpToDate(
@Nullable NoteDbChangeState state, RefCache changeRepoRefs, Change.Id changeId)
throws IOException {
if (PrimaryStorage.of(state) == NOTE_DB) {
return true; // Primary storage is NoteDb, up to date by definition.
}
if (state == null) {
return !changeRepoRefs.get(changeMetaRef(changeId)).isPresent();
}
return state.isChangeUpToDate(changeRepoRefs);
}
public static boolean areDraftsUpToDate(
@Nullable NoteDbChangeState state,
RefCache draftsRepoRefs,
Change.Id changeId,
Account.Id accountId)
throws IOException {
if (PrimaryStorage.of(state) == NOTE_DB) {
return true; // Primary storage is NoteDb, up to date by definition.
}
if (state == null) {
return !draftsRepoRefs.get(refsDraftComments(changeId, accountId)).isPresent();
}
return state.areDraftsUpToDate(draftsRepoRefs, accountId);
}
public static long getReadOnlySkew(Config cfg) {
return cfg.getTimeUnit("notedb", null, "maxTimestampSkew", 1000, TimeUnit.MILLISECONDS);
}
static Timestamp timeForReadOnlyCheck(long skewMs) {
// Subtract some slop in case the machine that set the change's read-only
// lease has a clock behind ours.
return new Timestamp(TimeUtil.nowMs() - skewMs);
}
public static void checkNotReadOnly(@Nullable Change change, long skewMs) {
checkNotReadOnly(parse(change), skewMs);
}
public static void checkNotReadOnly(@Nullable NoteDbChangeState state, long skewMs) {
if (state == null) {
return; // No state means ReviewDb primary non-read-only.
} else if (state.isReadOnly(timeForReadOnlyCheck(skewMs))) {
throw new OrmRuntimeException(
"change "
+ state.getChangeId()
+ " is read-only until "
+ state.getReadOnlyUntil().get());
}
}
private final Change.Id changeId;
private final PrimaryStorage primaryStorage;
private final Optional<RefState> refState;
private final Optional<Timestamp> readOnlyUntil;
public NoteDbChangeState(
Change.Id changeId,
PrimaryStorage primaryStorage,
Optional<RefState> refState,
Optional<Timestamp> readOnlyUntil) {
this.changeId = requireNonNull(changeId);
this.primaryStorage = requireNonNull(primaryStorage);
this.refState = requireNonNull(refState);
this.readOnlyUntil = requireNonNull(readOnlyUntil);
switch (primaryStorage) {
case REVIEW_DB:
checkArgument(
refState.isPresent(),
"expected RefState for change %s with primary storage %s",
changeId,
primaryStorage);
break;
case NOTE_DB:
checkArgument(
!refState.isPresent(),
"expected no RefState for change %s with primary storage %s",
changeId,
primaryStorage);
break;
default:
throw new IllegalStateException("invalid PrimaryStorage: " + primaryStorage);
}
}
public PrimaryStorage getPrimaryStorage() {
return primaryStorage;
}
public boolean isChangeUpToDate(RefCache changeRepoRefs) throws IOException {
if (primaryStorage == NOTE_DB) {
return true; // Primary storage is NoteDb, up to date by definition.
}
Optional<ObjectId> id = changeRepoRefs.get(changeMetaRef(changeId));
if (!id.isPresent()) {
return getChangeMetaId().equals(ObjectId.zeroId());
}
return id.get().equals(getChangeMetaId());
}
public boolean areDraftsUpToDate(RefCache draftsRepoRefs, Account.Id accountId)
throws IOException {
if (primaryStorage == NOTE_DB) {
return true; // Primary storage is NoteDb, up to date by definition.
}
Optional<ObjectId> id = draftsRepoRefs.get(refsDraftComments(changeId, accountId));
if (!id.isPresent()) {
return !getDraftIds().containsKey(accountId);
}
return id.get().equals(getDraftIds().get(accountId));
}
public boolean isUpToDate(RefCache changeRepoRefs, RefCache draftsRepoRefs) throws IOException {
if (primaryStorage == NOTE_DB) {
return true; // Primary storage is NoteDb, up to date by definition.
}
if (!isChangeUpToDate(changeRepoRefs)) {
return false;
}
for (Account.Id accountId : getDraftIds().keySet()) {
if (!areDraftsUpToDate(draftsRepoRefs, accountId)) {
return false;
}
}
return true;
}
public boolean isReadOnly(Timestamp now) {
return readOnlyUntil.isPresent() && now.before(readOnlyUntil.get());
}
public Optional<Timestamp> getReadOnlyUntil() {
return readOnlyUntil;
}
public NoteDbChangeState withReadOnlyUntil(Timestamp ts) {
return new NoteDbChangeState(changeId, primaryStorage, refState, Optional.of(ts));
}
public Change.Id getChangeId() {
return changeId;
}
public ObjectId getChangeMetaId() {
return refState().changeMetaId();
}
public ImmutableMap<Account.Id, ObjectId> getDraftIds() {
return refState().draftIds();
}
public Optional<RefState> getRefState() {
return refState;
}
private RefState refState() {
checkState(refState.isPresent(), "state for %s has no RefState: %s", changeId, this);
return refState.get();
}
@Override
public String toString() {
switch (primaryStorage) {
case REVIEW_DB:
if (!readOnlyUntil.isPresent()) {
// Don't include enum field, just IDs (though parse would accept it).
return refState().toString();
}
return primaryStorage.code + "=" + readOnlyUntil.get().getTime() + "," + refState.get();
case NOTE_DB:
if (!readOnlyUntil.isPresent()) {
return NOTE_DB_PRIMARY_STATE;
}
return primaryStorage.code + "=" + readOnlyUntil.get().getTime();
default:
throw new IllegalArgumentException("Unsupported PrimaryStorage: " + primaryStorage);
}
}
@Override
public int hashCode() {
return Objects.hash(changeId, primaryStorage, refState, readOnlyUntil);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof NoteDbChangeState)) {
return false;
}
NoteDbChangeState s = (NoteDbChangeState) o;
return changeId.equals(s.changeId)
&& primaryStorage.equals(s.primaryStorage)
&& refState.equals(s.refState)
&& readOnlyUntil.equals(s.readOnlyUntil);
}
}