diff --git a/.travis.yml b/.travis.yml index 335f55456..81024e9d0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: java jdk: - oraclejdk8 - - oraclejdk7 cache: directories: diff --git a/pom.xml b/pom.xml index 382e140dc..8dc561f2b 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ com.googlecode.objectify objectify - 5.1.21-SNAPSHOT + 5.1.26-SNAPSHOT Objectify App Engine The simplest convenient interface to the Google App Engine datastore @@ -46,17 +46,6 @@ - - - doclint-java8-disable - - [1.8,) - - - -Xdoclint:none - - - release-sign-artifacts @@ -77,6 +66,12 @@ sign + + + --pinentry-mode + loopback + + @@ -97,17 +92,17 @@ maven-compiler-plugin - 3.2 + 3.8.1 - 1.7 - 1.7 + 1.8 + 1.8 org.apache.maven.plugins maven-source-plugin - 2.4 + 3.1.0 @@ -120,7 +115,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 2.10.1 + 3.1.0 @@ -129,14 +124,15 @@ - ${javadoc.opts} + none + 8 org.apache.maven.plugins maven-surefire-plugin - 2.18 + 2.22.2 **/*Test*.java @@ -147,7 +143,7 @@ org.sonatype.plugins nexus-staging-maven-plugin - 1.6.5 + 1.6.8 true ossrh @@ -159,7 +155,7 @@ org.apache.maven.plugins maven-release-plugin - 2.5.1 + 2.5.3 true release-sign-artifacts @@ -173,7 +169,7 @@ org.projectlombok lombok - 1.16.4 + 1.18.8 provided diff --git a/src/main/java/com/googlecode/objectify/Key.java b/src/main/java/com/googlecode/objectify/Key.java index 2106c52f4..defaaa135 100644 --- a/src/main/java/com/googlecode/objectify/Key.java +++ b/src/main/java/com/googlecode/objectify/Key.java @@ -1,10 +1,12 @@ package com.googlecode.objectify; import com.google.appengine.api.datastore.KeyFactory; +import com.google.common.collect.Maps; import com.googlecode.objectify.annotation.Entity; import com.googlecode.objectify.impl.TypeUtils; import java.io.Serializable; +import java.util.Map; /** *

A typesafe wrapper for the datastore Key object.

@@ -238,6 +240,8 @@ public static com.google.appengine.api.datastore.Key key(Key typed) { else return typed.getRaw(); } + + private static final Map, String> kindCache = Maps.newConcurrentMap(); /** *

Determines the kind for a Class, as understood by the datastore. The first class in a @@ -246,11 +250,15 @@ public static com.google.appengine.api.datastore.Key key(Key typed) { *

If no @Entity annotation is found, just uses the simplename as is.

*/ public static String getKind(Class clazz) { - String kind = getKindRecursive(clazz); - if (kind == null) - return clazz.getSimpleName(); - else - return kind; + String kind = kindCache.get(clazz); + if (kind == null) { + kind = getKindRecursive(clazz); + if (kind == null) { + kind = clazz.getSimpleName(); + } + kindCache.put(clazz, kind); + } + return kind; } /** diff --git a/src/main/java/com/googlecode/objectify/ObjectifyFactory.java b/src/main/java/com/googlecode/objectify/ObjectifyFactory.java index 77af0845c..f0af964a8 100644 --- a/src/main/java/com/googlecode/objectify/ObjectifyFactory.java +++ b/src/main/java/com/googlecode/objectify/ObjectifyFactory.java @@ -55,9 +55,16 @@ public class ObjectifyFactory implements Forge /** Tracks stats */ protected EntityMemcacheStats memcacheStats = new EntityMemcacheStats(); - /** Manages caching of entities at a low level */ - protected EntityMemcache entityMemcache = new EntityMemcache(MEMCACHE_NAMESPACE, new CacheControlImpl(this), this.memcacheStats); - + /** + * Manages caching of entities at a low level. Lazily instantiated on the first register() of a cacheable entity. + */ + protected EntityMemcache entityMemcache; + + /** Override this if you need special behavior from your EntityMemcache */ + protected EntityMemcache createEntityMemcache() { + return new EntityMemcache(MEMCACHE_NAMESPACE, new CacheControlImpl(this), this.memcacheStats); + } + /** *

Construct an instance of the specified type. Objectify uses this method whenever possible to create * instances of entities, condition classes, or other types; by overriding this method you can substitute Guice or other @@ -168,6 +175,9 @@ public Objectify begin() { */ public void register(Class clazz) { this.registrar.register(clazz); + + if (this.entityMemcache == null && this.registrar.isCacheEnabled()) + this.entityMemcache = createEntityMemcache(); } /** diff --git a/src/main/java/com/googlecode/objectify/ObjectifyService.java b/src/main/java/com/googlecode/objectify/ObjectifyService.java index 59c1cb75e..82ab4f08f 100644 --- a/src/main/java/com/googlecode/objectify/ObjectifyService.java +++ b/src/main/java/com/googlecode/objectify/ObjectifyService.java @@ -5,6 +5,7 @@ import com.googlecode.objectify.cache.PendingFutures; import com.googlecode.objectify.util.Closeable; + import java.util.ArrayDeque; import java.util.Deque; @@ -109,17 +110,21 @@ public void close() { if (stack.isEmpty()) throw new IllegalStateException("You have already destroyed the Objectify context."); - // Same comment as above - we can't make claims about the state of the stack beacuse of dispatch forwarding - //if (stack.size() > 1) - // throw new IllegalStateException("You are trying to close the root session before all transactions have been unwound."); - - // The order of these three operations is significant + try { + // Same comment as above - we can't make claims about the state of the stack + // beacuse of dispatch forwarding + // if (stack.size() > 1) + // throw new IllegalStateException("You are trying to close the root session + // before all transactions have been unwound."); - ofy.flush(); + // The order of these three operations is significant - PendingFutures.completeAllPendingFutures(); + ofy.flush(); - stack.removeLast(); + PendingFutures.completeAllPendingFutures(); + } finally { + stack.removeLast(); + } } }; } diff --git a/src/main/java/com/googlecode/objectify/cache/CachingAsyncDatastoreService.java b/src/main/java/com/googlecode/objectify/cache/CachingAsyncDatastoreService.java index 28621396e..fef910253 100644 --- a/src/main/java/com/googlecode/objectify/cache/CachingAsyncDatastoreService.java +++ b/src/main/java/com/googlecode/objectify/cache/CachingAsyncDatastoreService.java @@ -275,8 +275,14 @@ public void success(Map result) if (value != null) buck.setNext(value); } - - memcache.putAll(uncached); + try{ + memcache.putAll(uncached); + } catch(Exception ex){ + // It is possible that memcache is unreachable. Continue if + // this happens as data was retrieved from datastore and + // will result in a cache miss on next access where we + // can try to cache again. + } } }; } diff --git a/src/main/java/com/googlecode/objectify/cache/EntityMemcache.java b/src/main/java/com/googlecode/objectify/cache/EntityMemcache.java index 00b00faa9..fc51a19fd 100644 --- a/src/main/java/com/googlecode/objectify/cache/EntityMemcache.java +++ b/src/main/java/com/googlecode/objectify/cache/EntityMemcache.java @@ -5,6 +5,7 @@ import com.google.appengine.api.memcache.ErrorHandlers; import com.google.appengine.api.memcache.Expiration; import com.google.appengine.api.memcache.IMemcacheServiceFactory; +import com.google.appengine.api.memcache.MemcacheService; import com.google.appengine.api.memcache.MemcacheService.CasValues; import com.google.appengine.api.memcache.MemcacheService.IdentifiableValue; import com.google.appengine.spi.ServiceFactoryFactory; @@ -19,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; import java.util.logging.Level; /** @@ -175,10 +177,17 @@ public EntityMemcache( MemcacheStats stats, IMemcacheServiceFactory memcacheServiceFactory) { - this.memcache = new KeyMemcacheService(memcacheServiceFactory.getMemcacheService(namespace)); + this(cacheControl, stats, memcacheServiceFactory.getMemcacheService(namespace)); + } + + public EntityMemcache( + CacheControl cacheControl, + MemcacheStats stats, + MemcacheService memcacheService) + { + this.memcache = new KeyMemcacheService(memcacheService); this.memcache.setErrorHandler(ErrorHandlers.getConsistentLogAndContinue(Level.SEVERE)); - this.memcacheWithRetry = new KeyMemcacheService( - MemcacheServiceRetryProxy.createProxy(memcacheServiceFactory.getMemcacheService(namespace))); + this.memcacheWithRetry = new KeyMemcacheService(MemcacheServiceRetryProxy.createProxy(memcacheService)); this.stats = stats; this.cacheControl = cacheControl; } @@ -238,10 +247,11 @@ public Map getAll(Iterable keys) if (!cold.isEmpty()) { - // The cache is cold for those values, so start them out with nulls that we can make an IV for - this.memcache.putAll(cold); - try { + // The cache is cold for those values, so start them out with nulls that we can make an IV for + // It is possible that memcache is unreachable, catch that failure. + this.memcache.putAll(cold); + Map ivs2 = this.memcache.getIdentifiables(cold.keySet()); ivs.putAll(ivs2); } catch (Exception ex) { diff --git a/src/main/java/com/googlecode/objectify/impl/TypeUtils.java b/src/main/java/com/googlecode/objectify/impl/TypeUtils.java index 1008e1877..2cdae9446 100644 --- a/src/main/java/com/googlecode/objectify/impl/TypeUtils.java +++ b/src/main/java/com/googlecode/objectify/impl/TypeUtils.java @@ -10,6 +10,11 @@ import java.util.HashMap; import java.util.Map; +import com.google.common.base.Optional; +import com.google.common.collect.Maps; + +import lombok.Value; + /** */ public class TypeUtils @@ -30,13 +35,20 @@ public class TypeUtils private TypeUtils() { } + private static final Map, Constructor> noArgConstructorCache = Maps.newConcurrentMap(); + /** * Throw an IllegalStateException if the class does not have a no-arg constructor. */ public static Constructor getNoArgConstructor(Class clazz) { try { - Constructor ctor = clazz.getDeclaredConstructor(new Class[0]); - ctor.setAccessible(true); + @SuppressWarnings("unchecked") + Constructor ctor = (Constructor) noArgConstructorCache.get(clazz); + if (ctor == null) { + ctor = clazz.getDeclaredConstructor(new Class[0]); + ctor.setAccessible(true); + noArgConstructorCache.put(clazz, ctor); + } return ctor; } catch (NoSuchMethodException e) { @@ -113,11 +125,27 @@ public static A getAnnotation(Annotation[] annotations, C return null; } + @Value + private static final class DeclaredAnnotationCacheKey { + private final Class onClass; + private final Class annotationType; + } + + private static final Map> declaredAnnotationCache = Maps.newConcurrentMap(); + /** * Get the declared annotation, ignoring any inherited annotations */ public static A getDeclaredAnnotation(Class onClass, Class annotationType) { - return getAnnotation(onClass.getDeclaredAnnotations(), annotationType); + final DeclaredAnnotationCacheKey key = new DeclaredAnnotationCacheKey(onClass, annotationType); + + @SuppressWarnings("unchecked") + Optional value = (Optional) declaredAnnotationCache.get(key); + if (value == null) { + value = Optional.fromNullable(getAnnotation(onClass.getDeclaredAnnotations(), annotationType)); + declaredAnnotationCache.put(key, value); + } + return value.orNull(); } /** diff --git a/src/main/java/com/googlecode/objectify/impl/WriteEngine.java b/src/main/java/com/googlecode/objectify/impl/WriteEngine.java index ca16678fb..2294d9296 100644 --- a/src/main/java/com/googlecode/objectify/impl/WriteEngine.java +++ b/src/main/java/com/googlecode/objectify/impl/WriteEngine.java @@ -65,8 +65,11 @@ public Result, E>> save(Iterable entities) { final SaveContext ctx = new SaveContext(); + // Need to make a copy of the original list because someone might clear it while we are async + final List original = Lists.newArrayList(entities); + final List entityList = new ArrayList<>(); - for (E obj: entities) { + for (E obj: original) { if (obj == null) throw new NullPointerException("Attempted to save a null entity"); @@ -80,9 +83,6 @@ public Result, E>> save(Iterable entities) { } } - // Need to make a copy of the original list because someone might clear it while we are async - final List original = Lists.newArrayList(entities); - // The CachingDatastoreService needs its own raw transaction Future> raw = ads.put(getTransactionRaw(), entityList); Result> adapted = new ResultAdapter<>(raw); diff --git a/src/main/java/com/googlecode/objectify/impl/translate/ByteArrayTranslatorFactory.java b/src/main/java/com/googlecode/objectify/impl/translate/ByteArrayTranslatorFactory.java index 837b3421f..e09162bff 100644 --- a/src/main/java/com/googlecode/objectify/impl/translate/ByteArrayTranslatorFactory.java +++ b/src/main/java/com/googlecode/objectify/impl/translate/ByteArrayTranslatorFactory.java @@ -30,13 +30,7 @@ protected ValueTranslator createValueTranslator(TypeKey return new ValueTranslator(Object.class) { @Override public byte[] loadValue(Object node, LoadContext ctx, Path path) throws SkipException { - if (node instanceof Blob) { - return ((Blob)node).getBytes(); - } else if (node instanceof ShortBlob) { - return ((ShortBlob)node).getBytes(); - } else { - throw new IllegalStateException("Can't convert " + node + " to a byte array"); - } + return getBytesFromBlob(node); } @Override @@ -45,4 +39,14 @@ public Object saveValue(byte[] pojo, boolean index, SaveContext ctx, Path path) } }; } + + public static byte[] getBytesFromBlob(final Object node) { + if (node instanceof Blob) { + return ((Blob)node).getBytes(); + } else if (node instanceof ShortBlob) { + return ((ShortBlob)node).getBytes(); + } else { + throw new IllegalStateException("Can't convert " + node + " to a byte array"); + } + } } diff --git a/src/main/java/com/googlecode/objectify/impl/translate/SerializeTranslatorFactory.java b/src/main/java/com/googlecode/objectify/impl/translate/SerializeTranslatorFactory.java index d9fca48c1..63c8300e3 100644 --- a/src/main/java/com/googlecode/objectify/impl/translate/SerializeTranslatorFactory.java +++ b/src/main/java/com/googlecode/objectify/impl/translate/SerializeTranslatorFactory.java @@ -23,25 +23,27 @@ * * @author Jeff Schnitzer */ -public class SerializeTranslatorFactory implements TranslatorFactory +public class SerializeTranslatorFactory implements TranslatorFactory { private static final Logger log = Logger.getLogger(SerializeTranslatorFactory.class.getName()); @Override - public Translator create(TypeKey tk, CreateContext ctx, Path path) { + public Translator create(TypeKey tk, CreateContext ctx, Path path) { final Serialize serializeAnno = tk.getAnnotationAnywhere(Serialize.class); // We only work with @Serialize classes if (serializeAnno == null) return null; - return new ValueTranslator(Blob.class) { + return new ValueTranslator(Object.class) { @Override - protected Object loadValue(Blob value, LoadContext ctx, Path path) throws SkipException { + protected Object loadValue(Object value, LoadContext ctx, Path path) throws SkipException { + final byte[] bytes = ByteArrayTranslatorFactory.getBytesFromBlob(value); + // Need to be careful here because we don't really know if the data was serialized or not. Start // with whatever the annotation says, and if that doesn't work, try the other option. try { - ByteArrayInputStream bais = new ByteArrayInputStream(value.getBytes()); + ByteArrayInputStream bais = new ByteArrayInputStream(bytes); // Start with the annotation boolean unzip = serializeAnno.zip(); @@ -61,7 +63,7 @@ protected Object loadValue(Blob value, LoadContext ctx, Path path) throws SkipEx } @Override - protected Blob saveValue(Object value, boolean index, SaveContext ctx, Path path) throws SkipException { + protected Object saveValue(Object value, boolean index, SaveContext ctx, Path path) throws SkipException { try { ByteArrayOutputStream baos = new ByteArrayOutputStream(); OutputStream out = baos; @@ -91,7 +93,7 @@ private Object readObject(ByteArrayInputStream bais, boolean unzip) throws IOExc if (unzip) in = new InflaterInputStream(in); - ObjectInputStream ois = new ObjectInputStream(in); + final ObjectInputStream ois = new ObjectInputStream(in); return ois.readObject(); } }; diff --git a/src/test/java/com/googlecode/objectify/test/EmbeddedMapTests.java b/src/test/java/com/googlecode/objectify/test/EmbeddedMapTests.java index 1f694ff06..6b29f2236 100644 --- a/src/test/java/com/googlecode/objectify/test/EmbeddedMapTests.java +++ b/src/test/java/com/googlecode/objectify/test/EmbeddedMapTests.java @@ -1,5 +1,6 @@ package com.googlecode.objectify.test; +import com.google.common.collect.Lists; import com.googlecode.objectify.Key; import com.googlecode.objectify.annotation.Entity; import com.googlecode.objectify.annotation.Id; @@ -10,6 +11,7 @@ import com.googlecode.objectify.test.util.TestBase; import org.testng.annotations.Test; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -114,9 +116,30 @@ public void testMapWithSet() throws Exception { final MapWithSetIssue mws = new MapWithSetIssue(); mws.mapWithSet.put("key", Collections.singleton("value")); - ofy().saveClearLoad(mws); //failure here: java.util.HashMap cannot be cast to java.util.Collection + + final MapWithSetIssue fetched = ofy().saveClearLoad(mws); + + assert fetched.mapWithSet.equals(mws.mapWithSet); + } + + @Entity + public static class MapWithArrayList { + @Id Long id; + @Index + private Map> map = new HashMap<>(); } + @Test + public void testMapWithArrayList() throws Exception { + fact().register(MapWithArrayList.class); + + final MapWithArrayList mwa = new MapWithArrayList(); + mwa.map.put("key", Lists.newArrayList("value")); + + final MapWithArrayList fetched = ofy().saveClearLoad(mwa); + + assert fetched.map.equals(mwa.map); + } // // diff --git a/src/test/java/com/googlecode/objectify/test/OutOfMemoryTest.java b/src/test/java/com/googlecode/objectify/test/OutOfMemoryTest.java new file mode 100644 index 000000000..aa246cd5f --- /dev/null +++ b/src/test/java/com/googlecode/objectify/test/OutOfMemoryTest.java @@ -0,0 +1,124 @@ +package com.googlecode.objectify.test; + +import static com.googlecode.objectify.test.util.TestObjectifyService.fact; +import static com.googlecode.objectify.test.util.TestObjectifyService.ofy; + +import com.google.appengine.api.datastore.AsyncDatastoreService; +import com.google.appengine.api.datastore.DatastoreServiceConfig; +import com.googlecode.objectify.ObjectifyService; +import com.googlecode.objectify.annotation.Cache; +import com.googlecode.objectify.annotation.Entity; +import com.googlecode.objectify.annotation.Id; +import com.googlecode.objectify.cache.CachingAsyncDatastoreService; +import com.googlecode.objectify.test.util.GAETestBase; +import com.googlecode.objectify.test.util.TestObjectifyFactory; +import com.googlecode.objectify.test.util.TestObjectifyService; +import com.googlecode.objectify.util.Closeable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.NoSuchElementException; +import java.util.concurrent.CompletableFuture; + +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** + * In unfortunate situations, async get operations can throw OutOfMemoryErrors. + * If this occurs in an App Engine environment with HTTP requests using + * ObjectifyFilter, when using a caching datastore service, ObjectifyService + * will retry the problematic operation when completing futures registered in + * PendingFutures - where it's very likely to get an OutOfMemoryException again. + * This can prevent the stack {@link ObjectifyService} to be popped, thus + * leaking a huge amount of memory. As GAE uses a threadpool, this leak will + * then stick with the instance. This test asserts that the polluted Objectify + * instance is not kept in the stack. + */ +public class OutOfMemoryTest extends GAETestBase { + + @AfterMethod + public void tearDown() { + // TestObjectifyService.setFactory(new TestObjectifyFactory()); + } + + @BeforeMethod + public void setUp() throws Exception { + // set up an Objectify with a datastore service that throws OutOfMemoryErrors on + // get operations + TestObjectifyService.setFactory(new TestObjectifyFactory() { + @Override + public AsyncDatastoreService createAsyncDatastoreService(DatastoreServiceConfig cfg, boolean globalCache) { + final AsyncDatastoreService ads = super.createRawAsyncDatastoreService(cfg); + AsyncDatastoreService throwingAds = (AsyncDatastoreService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] { AsyncDatastoreService.class }, + new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (method.getName().equals("get")) { + return CompletableFuture.runAsync(new Runnable() { + + @Override + public void run() { + throw new OutOfMemoryError(); + } + }); + } + return method.invoke(ads, args); + } + }); + // use a caching wrapper so that the futures are registered in PendingFutures + return new CachingAsyncDatastoreService(throwingAds, entityMemcache); + } + }); + + fact().register(MemoryConsumingEntity.class); + } + + @Test + public void stackClearedOnOutOfMemoryError() throws Exception { + // save an entity so that we can load it + try (Closeable root = TestObjectifyService.begin()) { + MemoryConsumingEntity entity = new MemoryConsumingEntity(1l); + ofy().defer().save().entity(entity); + } + try { + // trigger a load as if it was started in a web request with ObjectifyFilter + try (Closeable root = TestObjectifyService.begin()) { + ofy().load().type(MemoryConsumingEntity.class).id(1l).now(); + } + } catch (Throwable t) { + Assert.assertTrue(t instanceof OutOfMemoryError); + } + try { + TestObjectifyService.pop(); + Assert.fail(); + } catch (NoSuchElementException e) { + // expected, the stack is cleared in the Closeable + } + } + + @Entity + @Cache + @Data + @NoArgsConstructor + public static class MemoryConsumingEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + private Long id; + + public MemoryConsumingEntity(Long id) { + this.id = id; + } + + } + +} diff --git a/src/test/java/com/googlecode/objectify/test/SaveTests.java b/src/test/java/com/googlecode/objectify/test/SaveTests.java new file mode 100644 index 000000000..a123ea841 --- /dev/null +++ b/src/test/java/com/googlecode/objectify/test/SaveTests.java @@ -0,0 +1,70 @@ +package com.googlecode.objectify.test; + +import static com.googlecode.objectify.ObjectifyService.factory; +import static com.googlecode.objectify.ObjectifyService.ofy; + +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.googlecode.objectify.Key; +import com.googlecode.objectify.annotation.Cache; +import com.googlecode.objectify.annotation.Entity; +import com.googlecode.objectify.annotation.Id; +import com.googlecode.objectify.annotation.OnSave; +import com.googlecode.objectify.impl.WriteEngine; +import com.googlecode.objectify.test.util.TestBase; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +import org.testng.annotations.Test; + +public class SaveTests extends TestBase { + + /** + * The {@link WriteEngine} previously iterated twice on the {@link Iterables} + * passed as argument. If the iterable doesn't return the same set of entities + * upon the second iteration, it can lead to problems. + * + * This can easily happen if there's a filtering iterable that uses a predicate + * relying on a property changed by an {@link OnSave} hook. + */ + @Test + public void testSaveChangingIterable() { + factory().register(EntityWithOnSave.class); + List entities = Lists.newArrayList(new EntityWithOnSave(1L, null)); + Iterable entitiesWithoutDefaultValue = Iterables.filter(entities, + new Predicate() { + @Override + public boolean apply(EntityWithOnSave entity) { + return entity.getSomeValue() == null; + } + }); + Map, EntityWithOnSave> saveResult = ofy().save().entities(entitiesWithoutDefaultValue) + .now(); + assert 1 == saveResult.size(); + } + + @Entity + @Cache + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class EntityWithOnSave { + @Id + private Long id; + + private String someValue; + + @OnSave + public void onSave() { + if (someValue == null) { + someValue = "some calculated default value"; + } + } + } +}