Skip to content

Commit a20eb8d

Browse files
authored
Fix failures in retries when inserting new objects (#2788)
Given an entity with auto-filled id fields (annotated with @GeneratedValue, with null as initial value), when inserting it using Hibernate, the id fields will be filled with non-nulls even if the transaction fails. If the same entity instance is used again in a retry, Hibernate mistakes it as a detached entity and raises an error. The work around is to make a new copy of the entity in each transaction. This PR applies this pattern to affected entity types. We considered applying this pattern to JpaTransactionManagerImpl's insert method so that individual call sites do not have to change. However, we decided against it because: - It is unnecessary for entity types that do not have auto-filled id - The JpaTransactionManager cannot tell if copying is cheap or expensive. It is better exposing this to the user. - The JpaTransactionManager needs to know how to clone entities. A new interface may need to be introduced just for a handful of use cases.
1 parent 338b8ed commit a20eb8d

13 files changed

Lines changed: 192 additions & 59 deletions

core/src/main/java/google/registry/model/smd/SignedMarkRevocationList.java

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,4 @@ public DateTime getCreationTime() {
9595
public int size() {
9696
return revokes.size();
9797
}
98-
99-
/** Save this list to Cloud SQL. Returns {@code this}. */
100-
public SignedMarkRevocationList save() {
101-
SignedMarkRevocationListDao.save(this);
102-
return this;
103-
}
10498
}

core/src/main/java/google/registry/model/smd/SignedMarkRevocationListDao.java

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,25 @@ static SignedMarkRevocationList load() {
4444
return smdrl.orElseGet(() -> SignedMarkRevocationList.create(START_OF_TIME, ImmutableMap.of()));
4545
}
4646

47-
/** Save the given {@link SignedMarkRevocationList} */
48-
static void save(SignedMarkRevocationList signedMarkRevocationList) {
49-
tm().transact(() -> tm().insert(signedMarkRevocationList));
47+
/**
48+
* Persists a {@link SignedMarkRevocationList} instance and returns the persisted entity.
49+
*
50+
* <p>Note that the input parameter is untouched. Use the returned object if metadata fields like
51+
* {@code revisionId} are needed.
52+
*/
53+
public static SignedMarkRevocationList save(SignedMarkRevocationList signedMarkRevocationList) {
54+
var persisted =
55+
tm().transact(
56+
() -> {
57+
var entity =
58+
SignedMarkRevocationList.create(
59+
signedMarkRevocationList.getCreationTime(),
60+
ImmutableMap.copyOf(signedMarkRevocationList.revokes));
61+
tm().insert(entity);
62+
return entity;
63+
});
5064
logger.atInfo().log(
51-
"Inserted %,d signed mark revocations into Cloud SQL.",
52-
signedMarkRevocationList.revokes.size());
65+
"Inserted %,d signed mark revocations into Cloud SQL.", persisted.revokes.size());
66+
return persisted;
5367
}
5468
}

core/src/main/java/google/registry/model/tld/label/PremiumListDao.java

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -133,24 +133,30 @@ public static PremiumList save(String name, CurrencyUnit currencyUnit, List<Stri
133133
}
134134

135135
/** Saves the given premium list (and its premium list entries) to Cloud SQL. */
136-
public static PremiumList save(PremiumList premiumList) {
137-
tm().transact(
138-
() -> {
139-
tm().insert(premiumList);
140-
tm().getEntityManager().flush(); // This populates the revisionId.
141-
long revisionId = premiumList.getRevisionId();
142-
143-
if (!isNullOrEmpty(premiumList.getLabelsToPrices())) {
144-
ImmutableSet.Builder<PremiumEntry> entries = new ImmutableSet.Builder<>();
145-
premiumList
146-
.getLabelsToPrices()
147-
.forEach(
148-
(key, value) -> entries.add(PremiumEntry.create(revisionId, value, key)));
149-
tm().insertAll(entries.build());
150-
}
151-
});
152-
premiumListCache.invalidate(premiumList.getName());
153-
return premiumList;
136+
public static PremiumList save(PremiumList premiumListToPersist) {
137+
PremiumList persisted =
138+
tm().transact(
139+
() -> {
140+
// Make a new copy in each attempt to insert. See javadoc of the insert method for
141+
// more information.
142+
PremiumList premiumList = premiumListToPersist.asBuilder().build();
143+
tm().insert(premiumList);
144+
tm().getEntityManager().flush(); // This populates the revisionId.
145+
long revisionId = premiumList.getRevisionId();
146+
147+
if (!isNullOrEmpty(premiumList.getLabelsToPrices())) {
148+
ImmutableSet.Builder<PremiumEntry> entries = new ImmutableSet.Builder<>();
149+
premiumList
150+
.getLabelsToPrices()
151+
.forEach(
152+
(key, value) ->
153+
entries.add(PremiumEntry.create(revisionId, value, key)));
154+
tm().insertAll(entries.build());
155+
}
156+
return premiumList;
157+
});
158+
premiumListCache.invalidate(persisted.getName());
159+
return persisted;
154160
}
155161

156162
public static void delete(PremiumList premiumList) {

core/src/main/java/google/registry/model/tld/label/ReservedListDao.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,26 @@ public class ReservedListDao {
2727

2828
private ReservedListDao() {}
2929

30-
/** Persist a new reserved list to Cloud SQL. */
31-
public static void save(ReservedList reservedList) {
30+
/**
31+
* Persists a new reserved list to Cloud SQL and returns the persisted entity.
32+
*
33+
* <p>Note that the input parameter is untouched. Use the returned object if metadata fields like
34+
* {@code revisionId} are needed.
35+
*/
36+
public static ReservedList save(ReservedList reservedList) {
3237
checkArgumentNotNull(reservedList, "Must specify reservedList");
3338
logger.atInfo().log("Saving reserved list %s to Cloud SQL.", reservedList.getName());
34-
tm().transact(() -> tm().insert(reservedList));
39+
var persisted =
40+
tm().transact(
41+
() -> {
42+
var entity = reservedList.asBuilder().build();
43+
tm().insert(entity);
44+
return entity;
45+
});
3546
logger.atInfo().log(
3647
"Saved reserved list %s with %d entries to Cloud SQL.",
3748
reservedList.getName(), reservedList.getReservedListEntries().size());
49+
return persisted;
3850
}
3951

4052
/** Deletes a reserved list from Cloud SQL. */

core/src/main/java/google/registry/model/tmch/ClaimsListDao.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,23 @@ private static LoadingCache<Class<ClaimsListDao>, ClaimsList> createCache(Durati
4949
return CacheUtils.newCacheBuilder(expiry).build(ignored -> ClaimsListDao.getUncached());
5050
}
5151

52-
/** Saves the given {@link ClaimsList} to Cloud SQL. */
53-
public static void save(ClaimsList claimsList) {
54-
tm().transact(() -> tm().insert(claimsList));
55-
CACHE.put(ClaimsListDao.class, claimsList);
52+
/**
53+
* Persists a {@link ClaimsList} instance and returns the persisted entity.
54+
*
55+
* <p>Note that the input parameter is untouched. Use the returned object if metadata fields like
56+
* {@code revisionId} are needed.
57+
*/
58+
public static ClaimsList save(ClaimsList claimsList) {
59+
var persisted =
60+
tm().transact(
61+
() -> {
62+
var entity =
63+
ClaimsList.create(claimsList.tmdbGenerationTime, claimsList.labelsToKeys);
64+
tm().insert(entity);
65+
return entity;
66+
});
67+
CACHE.put(ClaimsListDao.class, persisted);
68+
return persisted;
5669
}
5770

5871
/** Returns the most recent revision of the {@link ClaimsList} from the cache. */

core/src/main/java/google/registry/persistence/transaction/JpaTransactionManagerImpl.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,18 @@ public DateTime getTransactionTime() {
344344
return txnInfo.transactionTime;
345345
}
346346

347+
/**
348+
* Inserts an object into the database.
349+
*
350+
* <p>If {@code entity} has an auto-generated identity field (i.e., a field annotated with {@link
351+
* jakarta.persistence.GeneratedValue}), the caller must not assign a value to this field,
352+
* otherwise Hibernate would mistake the entity as detached and raise an error.
353+
*
354+
* <p>The practical implication of the above is that when inserting such an entity using a
355+
* retriable transaction , the entity should be instantiated inside the transaction body. A failed
356+
* attempt may still assign and ID to the entity, therefore reusing the same entity would cause
357+
* retries to fail.
358+
*/
347359
@Override
348360
public void insert(Object entity) {
349361
checkArgumentNotNull(entity, "entity must be specified");

core/src/main/java/google/registry/tmch/TmchSmdrlAction.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import com.google.common.flogger.FluentLogger;
2121
import google.registry.keyring.api.KeyModule.Key;
2222
import google.registry.model.smd.SignedMarkRevocationList;
23+
import google.registry.model.smd.SignedMarkRevocationListDao;
2324
import google.registry.request.Action;
2425
import google.registry.request.Action.GaeService;
2526
import google.registry.request.auth.Auth;
@@ -57,7 +58,7 @@ public void run() {
5758
} catch (GeneralSecurityException | IOException | PGPException e) {
5859
throw new RuntimeException(e);
5960
}
60-
smdrl.save();
61+
smdrl = SignedMarkRevocationListDao.save(smdrl);
6162
logger.atInfo().log(
6263
"Inserted %,d smd revocations into the database, created at %s.",
6364
smdrl.size(), smdrl.getCreationTime());

core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
import google.registry.model.reporting.DomainTransactionRecord.TransactionReportField;
180180
import google.registry.model.reporting.HistoryEntry;
181181
import google.registry.model.reporting.HistoryEntry.HistoryEntryId;
182+
import google.registry.model.smd.SignedMarkRevocationListDao;
182183
import google.registry.model.tld.Tld;
183184
import google.registry.model.tld.Tld.TldState;
184185
import google.registry.model.tld.Tld.TldType;
@@ -2727,8 +2728,9 @@ void testFail_startDateSunriseRegistration_wrongEncodedSignedMark() {
27272728

27282729
@Test
27292730
void testFail_startDateSunriseRegistration_revokedSignedMark() throws Exception {
2730-
SmdrlCsvParser.parse(TmchTestData.loadFile("smd/smdrl.csv").lines().collect(toImmutableList()))
2731-
.save();
2731+
SignedMarkRevocationListDao.save(
2732+
SmdrlCsvParser.parse(
2733+
TmchTestData.loadFile("smd/smdrl.csv").lines().collect(toImmutableList())));
27322734
createTld("tld", START_DATE_SUNRISE);
27332735
clock.setTo(SMD_VALID_TIME);
27342736
String revokedSmd =
@@ -2753,9 +2755,9 @@ void testFail_startDateSunriseRegistration_IdnRevokedSignedMark(
27532755
if (labels.isEmpty()) {
27542756
return;
27552757
}
2756-
SmdrlCsvParser.parse(
2757-
TmchTestData.loadFile("idn/idn_smdrl.csv").lines().collect(toImmutableList()))
2758-
.save();
2758+
SignedMarkRevocationListDao.save(
2759+
SmdrlCsvParser.parse(
2760+
TmchTestData.loadFile("idn/idn_smdrl.csv").lines().collect(toImmutableList())));
27592761
createTld("tld", START_DATE_SUNRISE);
27602762
clock.setTo(SMD_VALID_TIME);
27612763
String revokedSmd =

core/src/test/java/google/registry/model/smd/SignedMarkRevocationListDaoTest.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616

1717
import static com.google.common.truth.Truth.assertThat;
1818
import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects;
19+
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
1920

2021
import com.google.common.collect.ImmutableMap;
2122
import google.registry.model.EntityTestCase;
23+
import jakarta.persistence.OptimisticLockException;
24+
import java.util.concurrent.atomic.AtomicBoolean;
2225
import org.junit.jupiter.api.Test;
2326

2427
public class SignedMarkRevocationListDaoTest extends EntityTestCase {
@@ -32,11 +35,29 @@ void testSave_success() {
3235
SignedMarkRevocationList list =
3336
SignedMarkRevocationList.create(
3437
fakeClock.nowUtc(), ImmutableMap.of("mark", fakeClock.nowUtc().minusHours(1)));
35-
SignedMarkRevocationListDao.save(list);
38+
list = SignedMarkRevocationListDao.save(list);
3639
SignedMarkRevocationList fromDb = SignedMarkRevocationListDao.load();
3740
assertAboutImmutableObjects().that(fromDb).isEqualExceptFields(list);
3841
}
3942

43+
@Test
44+
void testSave_retrySuccess() {
45+
SignedMarkRevocationList list =
46+
SignedMarkRevocationList.create(
47+
fakeClock.nowUtc(), ImmutableMap.of("mark", fakeClock.nowUtc().minusHours(1)));
48+
AtomicBoolean isFirstAttempt = new AtomicBoolean(true);
49+
tm().transact(
50+
() -> {
51+
SignedMarkRevocationListDao.save(list);
52+
if (isFirstAttempt.get()) {
53+
isFirstAttempt.set(false);
54+
throw new OptimisticLockException();
55+
}
56+
});
57+
SignedMarkRevocationList fromDb = SignedMarkRevocationListDao.load();
58+
assertAboutImmutableObjects().that(fromDb).isEqualExceptFields(list, "revisionId");
59+
}
60+
4061
@Test
4162
void testSaveAndLoad_emptyList() {
4263
SignedMarkRevocationList list =

core/src/test/java/google/registry/model/smd/SignedMarkRevocationListTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ private SignedMarkRevocationList createSaveGetHelper(int rows) {
4848
for (int i = 0; i < rows; i++) {
4949
revokes.put(Integer.toString(i), clock.nowUtc());
5050
}
51-
SignedMarkRevocationList.create(clock.nowUtc(), revokes.build()).save();
51+
SignedMarkRevocationListDao.save(
52+
SignedMarkRevocationList.create(clock.nowUtc(), revokes.build()));
5253
SignedMarkRevocationList res = SignedMarkRevocationList.get();
5354
assertThat(res.size()).isEqualTo(rows);
5455
return res;

0 commit comments

Comments
 (0)