google-nomulus/java/google/registry/model/registry/label/PremiumList.java
cgoldfeder 5098b03af4 DeReference the codebase
This change replaces all Ref objects in the code with Key objects. These are
stored in datastore as the same object (raw datastore keys), so this is not
a model change.

Our best practices doc says to use Keys not Refs because:
 * The .get() method obscures what's actually going on
   - Much harder to visually audit the code for datastore loads
   - Hard to distinguish Ref<T> get()'s from Optional get()'s and Supplier get()'s
 * Implicit ofy().load() offers much less control
   - Antipattern for ultimate goal of making Ofy injectable
   - Can't control cache use or batch loading without making ofy() explicit anyway
 * Serialization behavior is surprising and could be quite dangerous/incorrect
   - Can lead to serialization errors. If it actually worked "as intended",
     it would lead to a Ref<> on a serialized object being replaced upon
     deserialization with a stale copy of the old value, which could potentially
     break all kinds of transactional expectations
 * Having both Ref<T> and Key<T> introduces extra boilerplate everywhere
   - E.g. helper methods all need to have Ref and Key overloads, or you need to
     call .key() to get the Key<T> for every Ref<T> you want to pass in
   - Creating a Ref<T> is more cumbersome, since it doesn't have all the create()
     overloads that Key<T> has, only create(Key<T>) and create(Entity) - no way to
     create directly from kind+ID/name, raw Key, websafe key string, etc.

(Note that Refs are treated specially by Objectify's @Load method and Keys are not;
we don't use that feature, but it is the one advantage Refs have over Keys.)

The direct impetus for this change is that I am trying to audit our use of memcache,
and the implicit .get() calls to datastore were making that very hard.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=131965491
2016-09-02 13:50:20 -04:00

387 lines
14 KiB
Java

// Copyright 2016 The Domain Registry Authors. All Rights Reserved.
//
// 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 google.registry.model.registry.label;
import static com.google.appengine.api.datastore.DatastoreServiceFactory.getDatastoreService;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.Iterables.partition;
import static google.registry.model.common.EntityGroupRoot.getCrossTldKey;
import static google.registry.model.ofy.ObjectifyService.allocateId;
import static google.registry.model.ofy.ObjectifyService.ofy;
import static google.registry.model.ofy.Ofy.RECOMMENDED_MEMCACHE_EXPIRATION;
import static google.registry.util.CollectionUtils.nullToEmpty;
import static google.registry.util.CollectionUtils.nullToEmptyImmutableCopy;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import com.google.appengine.api.datastore.EntityNotFoundException;
import com.google.common.base.Function;
import com.google.common.base.Optional;
import com.google.common.base.Splitter;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.CacheLoader.InvalidCacheLoadException;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.util.concurrent.UncheckedExecutionException;
import com.googlecode.objectify.Key;
import com.googlecode.objectify.VoidWork;
import com.googlecode.objectify.Work;
import com.googlecode.objectify.annotation.Cache;
import com.googlecode.objectify.annotation.Entity;
import com.googlecode.objectify.annotation.Id;
import com.googlecode.objectify.annotation.Ignore;
import com.googlecode.objectify.annotation.OnLoad;
import com.googlecode.objectify.annotation.Parent;
import com.googlecode.objectify.cmd.Query;
import google.registry.config.RegistryEnvironment;
import google.registry.model.Buildable;
import google.registry.model.ImmutableObject;
import google.registry.model.annotations.VirtualEntity;
import google.registry.model.registry.Registry;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import javax.annotation.Nullable;
import org.joda.money.Money;
import org.joda.time.DateTime;
/**
* A premium list entity, persisted to Datastore, that is used to check domain label prices.
*/
@Entity
@Cache(expirationSeconds = RECOMMENDED_MEMCACHE_EXPIRATION)
public final class PremiumList extends BaseDomainLabelList<Money, PremiumList.PremiumListEntry> {
/** The number of premium list entry entities that are created and deleted per batch. */
private static final int TRANSACTION_BATCH_SIZE = 200;
/** Stores the revision key for the set of currently used premium list entry entities. */
Key<PremiumListRevision> revisionKey;
@Ignore
Map<String, PremiumListEntry> premiumListMap;
/** Virtual parent entity for premium list entry entities associated with a single revision. */
@Entity
@VirtualEntity
public static class PremiumListRevision extends ImmutableObject {
@Parent
Key<PremiumList> parent;
@Id
long revisionId;
static Key<PremiumListRevision> createKey(PremiumList parent) {
PremiumListRevision revision = new PremiumListRevision();
revision.parent = Key.create(parent);
revision.revisionId = allocateId();
return Key.create(revision);
}
}
private static LoadingCache<String, PremiumList> cache = CacheBuilder
.newBuilder()
.expireAfterWrite(
RegistryEnvironment.get().config().getDomainLabelListCacheDuration().getMillis(),
MILLISECONDS)
.build(new CacheLoader<String, PremiumList>() {
@Override
public PremiumList load(final String listName) {
return ofy().doTransactionless(new Work<PremiumList>() {
@Override
public PremiumList run() {
return ofy().load()
.type(PremiumList.class)
.parent(getCrossTldKey())
.id(listName)
.now();
}});
}});
/**
* Gets the premium price for the specified label on the specified tld, or returns Optional.absent
* if there is no premium price.
*/
public static Optional<Money> getPremiumPrice(String label, String tld) {
Registry registry = Registry.get(checkNotNull(tld, "tld"));
if (registry.getPremiumList() == null) {
return Optional.<Money> absent();
}
String listName = registry.getPremiumList().getName();
Optional<PremiumList> premiumList = get(listName);
if (!premiumList.isPresent()) {
throw new IllegalStateException("Could not load premium list named " + listName);
}
return premiumList.get().getPremiumPrice(label);
}
@OnLoad
private void loadPremiumListMap() {
try {
ImmutableMap.Builder<String, PremiumListEntry> entriesMap = new ImmutableMap.Builder<>();
if (revisionKey != null) {
for (PremiumListEntry entry : loadEntriesForCurrentRevision()) {
entriesMap.put(entry.getLabel(), entry);
}
}
premiumListMap = entriesMap.build();
} catch (Exception e) {
throw new RuntimeException("Could not retrieve entries for premium list " + name, e);
}
}
/**
* Gets the premium price for the specified label in the current PremiumList, or returns
* Optional.absent if there is no premium price.
*/
public Optional<Money> getPremiumPrice(String label) {
return Optional.fromNullable(
premiumListMap.containsKey(label) ? premiumListMap.get(label).getValue() : null);
}
public Map<String, PremiumListEntry> getPremiumListEntries() {
return nullToEmptyImmutableCopy(premiumListMap);
}
public Key<PremiumListRevision> getRevisionKey() {
return revisionKey;
}
/** Returns the PremiumList with the specified name. */
public static Optional<PremiumList> get(String name) {
try {
return Optional.of(cache.get(name));
} catch (InvalidCacheLoadException e) {
return Optional.<PremiumList> absent();
} catch (ExecutionException e) {
throw new UncheckedExecutionException("Could not retrieve premium list named " + name, e);
}
}
/**
* Returns whether a PremiumList of the given name exists, without going through the overhead
* of loading up all of the premium list entities. Also does not hit the cache.
*/
public static boolean exists(String name) {
try {
// Use DatastoreService to bypass the @OnLoad method that loads the premium list entries.
getDatastoreService().get(Key.create(getCrossTldKey(), PremiumList.class, name).getRaw());
return true;
} catch (EntityNotFoundException e) {
return false;
}
}
/**
* A premium list entry entity, persisted to Datastore. Each instance represents the price of a
* single label on a given TLD.
*/
@Entity
@Cache(expirationSeconds = RECOMMENDED_MEMCACHE_EXPIRATION)
public static class PremiumListEntry extends DomainLabelEntry<Money, PremiumListEntry>
implements Buildable {
@Parent
Key<PremiumListRevision> parent;
Money price;
@Override
public Money getValue() {
return price;
}
@Override
public Builder asBuilder() {
return new Builder(clone(this));
}
/**
* A builder for constructing {@link PremiumList} objects, since they are immutable.
*/
public static class Builder extends DomainLabelEntry.Builder<PremiumListEntry, Builder> {
public Builder() {}
private Builder(PremiumListEntry instance) {
super(instance);
}
public Builder setParent(Key<PremiumListRevision> parentKey) {
getInstance().parent = parentKey;
return this;
}
public Builder setPrice(Money price) {
getInstance().price = price;
return this;
}
}
}
@Override
@Nullable
PremiumListEntry createFromLine(String originalLine) {
List<String> lineAndComment = splitOnComment(originalLine);
if (lineAndComment.isEmpty()) {
return null;
}
String line = lineAndComment.get(0);
String comment = lineAndComment.get(1);
List<String> parts = Splitter.on(',').trimResults().splitToList(line);
checkArgument(parts.size() == 2, "Could not parse line in premium list: %s", originalLine);
return new PremiumListEntry.Builder()
.setLabel(parts.get(0))
.setPrice(Money.parse(parts.get(1)))
.setComment(comment)
.build();
}
/**
* Persists a PremiumList object to Datastore.
*
* <p> The flow here is: save the new premium list entries parented on that revision entity,
* save/update the PremiumList, and then delete the old premium list entries associated with the
* old revision.
*/
public PremiumList saveAndUpdateEntries() {
final Optional<PremiumList> oldPremiumList = get(name);
// Only update entries if there's actually a new revision of the list to save (which there will
// be if the list content changes, vs just the description/metadata).
boolean entriesToUpdate =
!oldPremiumList.isPresent()
|| !Objects.equals(oldPremiumList.get().revisionKey, this.revisionKey);
// If needed, save the new child entities in a series of transactions.
if (entriesToUpdate) {
for (final List<PremiumListEntry> batch
: partition(premiumListMap.values(), TRANSACTION_BATCH_SIZE)) {
ofy().transactNew(new VoidWork() {
@Override
public void vrun() {
ofy().save().entities(batch);
}});
}
}
// Save the new PremiumList itself.
PremiumList updated = ofy().transactNew(new Work<PremiumList>() {
@Override
public PremiumList run() {
DateTime now = ofy().getTransactionTime();
// Assert that the premium list hasn't been changed since we started this process.
checkState(
Objects.equals(
ofy().load().type(PremiumList.class).parent(getCrossTldKey()).id(name).now(),
oldPremiumList.orNull()),
"PremiumList was concurrently edited");
PremiumList newList = PremiumList.this.asBuilder()
.setLastUpdateTime(now)
.setCreationTime(
oldPremiumList.isPresent() ? oldPremiumList.get().creationTime : now)
.build();
ofy().save().entity(newList);
return newList;
}});
// Update the cache.
PremiumList.cache.put(name, updated);
// If needed and there are any, delete the entities under the old PremiumList.
if (entriesToUpdate && oldPremiumList.isPresent()) {
oldPremiumList.get().deleteEntries();
}
return updated;
}
@Override
public boolean refersToKey(Registry registry, Key<? extends BaseDomainLabelList<?, ?>> key) {
return Objects.equals(registry.getPremiumList(), key);
}
/** Deletes the PremiumList and all of its child entities. */
public void delete() {
ofy().transactNew(new VoidWork() {
@Override
public void vrun() {
ofy().delete().entity(PremiumList.this);
}});
deleteEntries();
cache.invalidate(name);
}
private void deleteEntries() {
if (revisionKey == null) {
return;
}
for (final List<Key<PremiumListEntry>> batch : partition(
loadEntriesForCurrentRevision().keys(),
TRANSACTION_BATCH_SIZE)) {
ofy().transactNew(new VoidWork() {
@Override
public void vrun() {
ofy().delete().keys(batch);
}});
}
}
private Query<PremiumListEntry> loadEntriesForCurrentRevision() {
return ofy().load().type(PremiumListEntry.class).ancestor(revisionKey);
}
@Override
public Builder asBuilder() {
return new Builder(clone(this));
}
/** A builder for constructing {@link PremiumList} objects, since they are immutable. */
public static class Builder extends BaseDomainLabelList.Builder<PremiumList, Builder> {
public Builder() {}
private Builder(PremiumList instance) {
super(instance);
}
private boolean entriesWereUpdated;
public Builder setPremiumListMap(ImmutableMap<String, PremiumListEntry> premiumListMap) {
entriesWereUpdated = true;
getInstance().premiumListMap = premiumListMap;
return this;
}
/** Updates the premiumListMap from input lines. */
public Builder setPremiumListMapFromLines(Iterable<String> lines) {
return setPremiumListMap(getInstance().parse(lines));
}
@Override
public PremiumList build() {
final PremiumList instance = getInstance();
if (getInstance().revisionKey == null || entriesWereUpdated) {
getInstance().revisionKey = PremiumListRevision.createKey(instance);
}
// When we build an instance, make sure all entries are parented on its revisionKey.
instance.premiumListMap = Maps.transformValues(
nullToEmpty(instance.premiumListMap),
new Function<PremiumListEntry, PremiumListEntry>() {
@Override
public PremiumListEntry apply(PremiumListEntry entry) {
return entry.asBuilder().setParent(instance.revisionKey).build();
}});
return super.build();
}
}
}