Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
2433e66
fix: Restoring the built-in sites can leave the main sites with empty…
boubaker Oct 12, 2026
32c86aa
fix: A site restore keeps the pages and navigation nodes of every con…
boubaker Oct 19, 2026
0364c31
fix: A site that a configuration overwrites at startup is imported fr…
boubaker Oct 19, 2026
f985294
fix: A failing atomic call logs that the transaction it joined is rol…
boubaker Oct 19, 2026
223ed9a
test: Pin the node cache eviction after the commit and run the fragme…
boubaker Oct 19, 2026
53bfd23
fix: A method whose transaction was marked rollback-only fails instea…
boubaker Oct 19, 2026
9422121
fix: A site configuration of an unknown type doesn't stop the startup…
boubaker Oct 19, 2026
809c5a0
fix: Restoring a site's pages imports them even when the startup didn…
boubaker Oct 19, 2026
96d2d0b
fix: State that a method that threw keeps its exception when its tran…
boubaker Oct 19, 2026
4997786
fix: State that a configuration file that can't be parsed stops the o…
boubaker Oct 19, 2026
4a86fed
fix: An orphan page that can't be removed doesn't stop the removal of…
boubaker Oct 20, 2026
2d20d07
fix: A page delete removes the layout of the page as stored EXO-91183
boubaker Oct 20, 2026
a299168
fix: Page and site writes work from the stored layout EXO-91183
boubaker Oct 21, 2026
3b43334
fix: A page properties save writes no layout column EXO-91183
boubaker Oct 21, 2026
65f8e9a
test: Pin the properties save's re-read of a property changed meanwhi…
boubaker Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
* REQUIRED. Support a current transaction, create a new one if none exists.
* Analogous to EJB or Spring transaction attribute of the same name.
*
* When a call that failed marked the transaction that the method began
* rollback-only, the transaction is rolled back: a method that returned then
* throws a jakarta.persistence.RollbackException, and a method that threw
* keeps its exception, unless ending the transaction fails. A method that
* joined an active transaction never throws it: the method that began the
* transaction does.
*
* This annotation is handled in org.exoplatform.commons.persistence.impl.ExoTransactionalAspect.
*
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
import org.exoplatform.portal.mop.SiteType;
import org.exoplatform.portal.pom.data.PortalData;

import lombok.AccessLevel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Setter;

@Data
@EqualsAndHashCode(callSuper = true)
Expand Down Expand Up @@ -88,6 +90,14 @@ public class PortalConfig extends ModelObject implements Cloneable {

private long bannerFileId;

/**
* True when the stored layout of the site couldn't be read because its root
* container is missing: a save keeps the stored layout, unless a layout is
* set with {@link #setPortalLayout(Container)}.
*/
@Setter(AccessLevel.NONE)
private boolean layoutUnresolved;

public PortalConfig() {
this(PORTAL_TYPE);
}
Expand Down Expand Up @@ -127,6 +137,7 @@ public PortalConfig(PortalData data) {
this.displayed = data.isDisplayed();
this.displayOrder = data.getDisplayOrder();
this.bannerFileId = data.getBannerFileId();
this.layoutUnresolved = data.isLayoutUnresolved();
}

public long getId() {
Expand All @@ -139,6 +150,7 @@ public long getId() {

public void setPortalLayout(Container container) {
portalLayout = container == null ? new SiteLayout() : new SiteLayout(container);
layoutUnresolved = false;
}

public String getProperty(String name) {
Expand Down Expand Up @@ -216,21 +228,23 @@ private static Container initDefaultLayout() {
}

public PortalData build() {
return new PortalData(storageId,
name,
type,
locale,
label,
description,
accessPermissions == null ? Collections.emptyList() : Arrays.asList(accessPermissions),
editPermission,
properties == null ? Collections.emptyMap() : new Properties(properties),
skin,
portalLayout.build(),
defaultLayout,
displayed,
displayOrder,
bannerFileId);
PortalData portalData = new PortalData(storageId,
name,
type,
locale,
label,
description,
accessPermissions == null ? Collections.emptyList() : Arrays.asList(accessPermissions),
editPermission,
properties == null ? Collections.emptyMap() : new Properties(properties),
skin,
portalLayout.build(),
defaultLayout,
displayed,
displayOrder,
bannerFileId);
portalData.setLayoutUnresolved(layoutUnresolved);
return portalData;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ public class PortalData extends ModelData {

private long bannerFileId;

/**
* True when the stored layout of the site couldn't be read because its root
* container is missing: a save of this data keeps the stored layout.
*/
private boolean layoutUnresolved;

private PortalData() {
super(null, null);
this.key = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,35 @@
/**
* Describe a method as transactional. This annotation will allow to:
* - Set current container to PortalContainer
* - Start a transaction for IDM and JPA sessions
* - Start the request lifecycle of the container: the IDM session transaction
* and the JPA EntityManager. The JPA writes made through kernel DAOs commit
* one DAO call at a time, unless {@link #atomic()} is set.
*
* This annotation is handled in io.meeds.commons.persistence.impl.ContainerTransactionalAspect.
* This annotation is handled in io.meeds.common.ContainerTransactionalAspect.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ContainerTransactional {

/**
* @return true to run the method in one JPA transaction, committed when it
* returns and rolled back when it throws. The writes made through
* the kernel EntityManager (kernel DAOs, ExoTransactional methods)
* join it. When the method begins the transaction, the Spring
* transactional methods, Spring Data repositories included, join it
* too, unless a Spring transaction of its own is active on the
* thread, and the callbacks they register run once it is committed
* or rolled back, unless a Spring transaction synchronization is
* already active on the thread: their callbacks are then left to
* that synchronization. A Spring cache eviction (CacheEvict) made
* inside the method takes effect at once, before the commit. When a
* call that failed marked the transaction rollback-only, a method
* that returned throws a RollbackException after the rollback, and
* a method that threw keeps its exception, unless the rollback
* fails. When a JPA transaction is already
* active on the thread, the method joins it and marks it
* rollback-only when it throws.
*/
boolean atomic() default false;

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,37 @@
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.transaction.support.TransactionSynchronization;

import org.exoplatform.commons.persistence.impl.EntityManagerService;
import org.exoplatform.container.ExoContainer;
import org.exoplatform.container.ExoContainerContext;
import org.exoplatform.container.PortalContainer;
import org.exoplatform.container.RootContainer;
import org.exoplatform.container.component.RequestLifeCycle;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;

import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityTransaction;
import jakarta.persistence.RollbackException;

/**
* An aspect processing point that will allow to encapsulate two behavior:
* - Set current container to PortalContainer
* - Start a transaction for IDM and JPA sessions
* - Start the request lifecycle of the container, and a JPA transaction when
* the annotation is {@link ContainerTransactional#atomic()}
*/
@Aspect
public class ContainerTransactionalAspect {

private static final Log LOG = ExoLogger.getLogger(ContainerTransactionalAspect.class);

/**
* The advice keeps this signature: the classes an addon wove at its own build
* call it as is.
*/
@Around("execution(* *(..)) && @annotation(io.meeds.common.ContainerTransactional)")
public Object around(ProceedingJoinPoint point) throws Throwable {
ExoContainer container = ExoContainerContext.getCurrentContainer();
Expand All @@ -46,7 +62,11 @@ public Object around(ProceedingJoinPoint point) throws Throwable {
}
RequestLifeCycle.begin(transactionContainer);
try {
return point.proceed();
if (isAtomic(point)) {
return proceedAtomically(point, transactionContainer.getComponentInstanceOfType(EntityManagerService.class));
} else {
return point.proceed();
}
} finally {
RequestLifeCycle.end();
if (transactionContainer != container) {
Expand All @@ -55,4 +75,95 @@ public Object around(ProceedingJoinPoint point) throws Throwable {
}
}

/**
* Runs the advised method in the active JPA transaction of the thread, or in
* a new one that is committed when the method returns and rolled back when it
* throws. The kernel DAOs annotated with ExoTransactional join it without
* committing it. When the method begins the transaction, the Spring
* transactional methods (Spring Data repositories included) join it too,
* unless a Spring transaction of its own is active on the thread.
*/
Object proceedAtomically(ProceedingJoinPoint point, EntityManagerService entityManagerService) throws Throwable {
if (entityManagerService == null) {
// No JPA persistence in the container: there is no JPA write to group
return point.proceed();
}
EntityManager entityManager = entityManagerService.getEntityManager();
if (entityManager == null) {
throw new IllegalStateException("No JPA EntityManager is started on the current thread: the method can't run atomically");
}
EntityTransaction transaction = entityManager.getTransaction();
if (transaction.isActive()) {
return proceedInActiveTransaction(point, transaction);
} else {
return proceedInNewTransaction(point,
transaction,
SpringTransactionScope.open(entityManagerService.getEntityManagerFactory(), entityManager));
}
}

private Object proceedInActiveTransaction(ProceedingJoinPoint point, EntityTransaction transaction) throws Throwable {
try {
return point.proceed();
} catch (Throwable e) { // NOSONAR the enclosing transaction can't be committed whatever the error
if (transaction.isActive()) {
transaction.setRollbackOnly();
// The method that began the transaction fails with a RollbackException
// once it returns over it: this log names the call that marked it
LOG.warn("{} failed in a transaction it joined, which is marked rollback-only: none of its writes is committed. Cause: {}",
point.getSignature(),
e.toString());
}
throw e;
}
}
Comment thread
boubaker marked this conversation as resolved.

private Object proceedInNewTransaction(ProceedingJoinPoint point,
EntityTransaction transaction,
SpringTransactionScope springScope) throws Throwable {
int status = TransactionSynchronization.STATUS_UNKNOWN;
boolean committing = false;
try {
transaction.begin();
Object result = point.proceed();
if (transaction.getRollbackOnly()) {
springScope.beforeCompletion();
springScope.close();
transaction.rollback();
status = TransactionSynchronization.STATUS_ROLLED_BACK;
throw new RollbackException("The transaction of " + point.getSignature()
+ " was marked rollback-only by a call that failed: none of its writes is committed");
}
springScope.beforeCommit();
springScope.close();
committing = true;
transaction.commit();
status = TransactionSynchronization.STATUS_COMMITTED;
springScope.afterCommit();
return result;
} catch (Throwable e) { // NOSONAR none of the method writes is committed whatever the error
if (transaction.isActive()) {
springScope.beforeCompletion();
springScope.close();
transaction.rollback();
status = TransactionSynchronization.STATUS_ROLLED_BACK;
} else if (committing && status == TransactionSynchronization.STATUS_UNKNOWN && e instanceof RollbackException) {
// The JPA provider rolls the transaction back before throwing
status = TransactionSynchronization.STATUS_ROLLED_BACK;
}
throw e;
} finally {
springScope.end(status);
}
}

boolean isAtomic(ProceedingJoinPoint point) {
if (point.getSignature() instanceof MethodSignature methodSignature) {
ContainerTransactional containerTransactional = methodSignature.getMethod().getAnnotation(ContainerTransactional.class);
return containerTransactional != null && containerTransactional.atomic();
} else {
return false;
}
}

}
Loading
Loading