MixinCleanerUpgradePlugin.java

/*
 * Copyright (C) 2003-2016 eXo Platform SAS.
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Affero General Public License
 * as published by the Free Software Foundation; either version 3
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, see<http://www.gnu.org/licenses/>.
 */
package org.exoplatform.platform.upgrade.plugins;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.servlet.ServletContext;
import javax.transaction.UserTransaction;

import org.apache.commons.lang.StringUtils;

import org.exoplatform.commons.api.settings.SettingService;
import org.exoplatform.commons.upgrade.UpgradeProductPlugin;
import org.exoplatform.commons.version.util.VersionComparator;
import org.exoplatform.container.PortalContainer;
import org.exoplatform.container.RootContainer.PortalContainerPostInitTask;
import org.exoplatform.container.xml.InitParams;
import org.exoplatform.container.xml.ValueParam;
import org.exoplatform.container.xml.ValuesParam;
import org.exoplatform.services.jcr.RepositoryService;
import org.exoplatform.services.jcr.core.ManageableRepository;
import org.exoplatform.services.jcr.ext.common.SessionProvider;
import org.exoplatform.services.jcr.impl.core.NodeImpl;
import org.exoplatform.services.jcr.impl.core.SessionImpl;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;
import org.exoplatform.services.transaction.TransactionService;

/**
 * Created by The eXo Platform SAS Author : Boubaker Khanfir
 * bkhanfir@exoplatform.com April 16, 2016
 */
public class MixinCleanerUpgradePlugin extends UpgradeProductPlugin {

  public static final String        DEFAULT_WORKSPACE_NAME         = "social";

  public static final String        MIGRATION_STATUS               = "migration.status";

  public static final int           UPDATE_LAST_NODE_FREQ          = 1000;

  private static final int          TRANSACTION_TIMEOUT_IN_SECONDS = 86400;

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

  private static final int          NODES_IN_ONE_TRANSACTION       = 100;

  private final PortalContainer     portalContainer;

  private final RepositoryService   repositoryService;

  private final TransactionService  txService;

  private String                    workspaceName;

  private long                      totalCount                     = 0;

  private Map<String, List<String>> mixinNames                     = new HashMap<String, List<String>>();                  ;

  private String                    jcrRootPath;

  private long                      maxTreatedNodes                = 0;

  private boolean                   upgradeFinished                = false;

  /**
   * @param portalContainer
   * @param repositoryService
   * @param txService
   * @param initParams workspace: workspace on which the operation will start.
   *          mixinsCleanup.includes, mixinsCleanup.excludes
   */
  public MixinCleanerUpgradePlugin(PortalContainer portalContainer,
                                   RepositoryService repositoryService,
                                   TransactionService txService,
                                   SettingService settingService,
                                   InitParams initParams) {
    super(settingService, initParams);
    this.repositoryService = repositoryService;
    this.txService = txService;
    this.portalContainer = portalContainer;
    ValueParam workspaceValueParam = initParams.getValueParam("workspace");
    if (workspaceValueParam != null) {
      workspaceName = workspaceValueParam.getValue();
    }
    if (StringUtils.isBlank(workspaceName)) {
      workspaceName = DEFAULT_WORKSPACE_NAME;
    }
    ValueParam pathParam = initParams.getValueParam("path");
    if (pathParam != null) {
      jcrRootPath = pathParam.getValue();
    }
    if (StringUtils.isBlank(jcrRootPath)) {
      jcrRootPath = "/";
    }

    ValuesParam mixinsValueParam = initParams.getValuesParam("mixinsCleanup.includes");
    if (mixinsValueParam != null) {
      List<String> mixins = mixinsValueParam.getValues();
      for (String mixin : mixins) {
        if (!StringUtils.isBlank(mixin)) {
          mixinNames.put(mixin, null);
        }
      }
    }
    if (mixinNames.isEmpty()) {
      LOG.warn("No mixins to cleanup, the mixins list is empty.");
    }
    ValueParam maxNodesParam = initParams.getValueParam("mixinsCleanup.maxNodes");
    if (maxNodesParam != null) {
      try {
        maxTreatedNodes = Long.parseLong(maxNodesParam.getValue());
        if (maxTreatedNodes < 0) {
          throw new IllegalArgumentException("'maxTreatedNodes' parameter should be a positive integer.");
        }
      } catch (Exception e) {
        LOG.error("Parameter '" + maxNodesParam.getName() + "' is not a valid number.", e);
      }
    }
    ValuesParam mixinsExceptionsValueParam = initParams.getValuesParam("mixinsCleanup.excludes");
    if (mixinsExceptionsValueParam != null) {
      List<String> mixins = mixinsExceptionsValueParam.getValues();
      // Values with value pattern MIXIN_TYPE;EXCEPTIONAL_NODE_TYPE_NAME
      // where EXCEPTIONAL_NODE_TYPE_NAME is the nodeType for which we shouldn't
      // clean up the mixin
      for (String mixinException : mixins) {
        String[] mixinExceptionParams = mixinException.split(";");
        String mixinName = mixinExceptionParams[0];
        String typeName = mixinExceptionParams[1];

        if (mixinNames.containsKey(mixinName)) {
          List<String> mixinExceptions = mixinNames.get(mixinName);
          if (mixinExceptions == null) {
            mixinExceptions = new ArrayList<String>();
            mixinNames.put(mixinName, mixinExceptions);
          }
          mixinExceptions.add(typeName);
        }
      }
    }
  }

  /**
   * @return true if current version is uppper or equals to than 4.3.0, else
   *         return false
   */
  @Override
  public boolean shouldProceedToUpgrade(String newVersion, String oldVersion) {
    String migrationStatus = getValue(MIGRATION_STATUS);

    return (VersionComparator.isAfter(newVersion, oldVersion) || VersionComparator.isSame(newVersion, oldVersion))
        && (migrationStatus == null || !migrationStatus.equals(UPGRADE_COMPLETED_STATUS));
  }

  /**
   * {@inheritDoc}
   */
  @Override
  public void processUpgrade(final String oldVersion, final String newVersion) {
    storeValueForPlugin(MIGRATION_STATUS, "0");

    PortalContainer.addInitTask(portalContainer.getPortalContext(), new PortalContainerPostInitTask() {
      @Override
      public void execute(ServletContext context, PortalContainer portalContainer) {
        // Execute the task in an asynchrounous way
        new Thread(new Runnable() {
          @Override
          public void run() {
            doMigration();
          }
        }, getName()).start();
      }
    });
  }

  /**
   * @return whether the operation is finished or not
   */
  public boolean isUpgradeFinished() {
    return upgradeFinished;
  }

  public long getTotalCount() {
    return totalCount;
  }

  private void doMigration() {
    LOG.info("Start migration, workspace = {}, root path = {}, maxNodes = {}", workspaceName, jcrRootPath, maxTreatedNodes);

    // Initialize counter
    totalCount = 0;

    Session session = null;
    UserTransaction transaction = null;
    try {
      // Get JCR Session
      ManageableRepository currentRepository = repositoryService.getCurrentRepository();
      session = SessionProvider.createSystemProvider().getSession(workspaceName, currentRepository);
      ((SessionImpl) session).setTimeout(TRANSACTION_TIMEOUT_IN_SECONDS);

      // Begin transaction
      transaction = beginTransaction();
      if (!session.itemExists(jcrRootPath)) {
        throw new IllegalStateException("Cannot procced to upgrade, path doesn't exist:" + workspaceName + ":" + jcrRootPath);
      }
      Node parentNode = (Node) session.getItem(jcrRootPath);

      transaction = cleanChildrenNodes(parentNode, session, transaction);
      session.save();
      transaction.commit();

      if (maxTreatedNodes > 0 && totalCount == maxTreatedNodes) {
        storeValueForPlugin(MIGRATION_STATUS, "" + totalCount);
      } else {
        storeValueForPlugin(MIGRATION_STATUS, UPGRADE_COMPLETED_STATUS);
      }

      LOG.info("Migration finished, proceeded nodes count = {}", totalCount);
    } catch (Exception e) {
      LOG.error("Migration interrupted because of the following error", e);
      try {
        session.refresh(false);
        transaction.rollback();
      } catch (Exception e1) {
        LOG.error("Error while rolling back transaction", e);
      }
    } finally {
      upgradeFinished = true;
      if (session != null) {
        session.logout();
      }
    }
  }

  private UserTransaction cleanChildrenNodes(Node parentNode, Session session, UserTransaction transaction) throws Exception {
    NodeIterator nodeIterator = ((NodeImpl) parentNode).getNodesLazily(1);
    while (nodeIterator.hasNext() && (maxTreatedNodes == 0 || totalCount < maxTreatedNodes)) {
      Node node = nodeIterator.nextNode();
      boolean proceeded = false;
      try {
        try {
          proceeded = cleanSingleNodeMixins(node);
        } catch (Exception e) {
          if (node != null) {
            node.refresh(false);
          }
          throw e;
        }
        // Commit transaction for each 100 cleaned nodes
        if (proceeded && totalCount > 0 && totalCount % NODES_IN_ONE_TRANSACTION == 0) {
          transaction = commitTransaction(node, session, transaction);
        }
        // Cleanup children nodes
        transaction = cleanChildrenNodes(node, session, transaction);
      } catch (Exception e) {
        // Rollback transation and decrease the proceeded nodes count
        long canceledNodes = totalCount % NODES_IN_ONE_TRANSACTION;
        LOG.error("Rollback '" + canceledNodes + "'  cleaned nodes", e);

        session.refresh(false);
        transaction.rollback();
        totalCount -= canceledNodes;

        // Restart transaction
        transaction = beginTransaction();
      }
    }
    return transaction;
  }

  private UserTransaction commitTransaction(Node node, Session session, UserTransaction transaction) throws Exception {
    // Commit mixin cleanup transaction
    session.save();
    transaction.commit();
    if (totalCount > 0 && totalCount % UPDATE_LAST_NODE_FREQ == 0) {
      // Store last updated node path each 1000 treated node
      storeValueForPlugin(MIGRATION_STATUS, "" + totalCount);
    }
    LOG.info("Migration in progress, proceeded nodes count = {}", totalCount);
    transaction = beginTransaction();
    return transaction;
  }

  private boolean cleanSingleNodeMixins(Node node) throws Exception {
    boolean proceeded = false;

    // Remove all mixins from nodes
    for (String mixinName : mixinNames.keySet()) {
      if (!node.isNodeType(mixinName)) {
        continue;
      }
      // Ignore deletion of mixins on exceptional node types (specified by
      // init-param)
      if (isExceptionalNodeType(node, mixinNames.get(mixinName))) {
        if (LOG.isDebugEnabled()) {
          LOG.debug("Ignore node: '{}', nodetype = '{}', remove mixin '{}'",
                    node.getPath(),
                    node.getPrimaryNodeType().getName(),
                    mixinName);
        }
      } else {
        if (LOG.isDebugEnabled()) {
          LOG.debug("Proceed node: '{}', nodetype = '{}', remove mixin '{}'",
                    node.getPath(),
                    node.getPrimaryNodeType().getName(),
                    mixinName);
        }
        node.removeMixin(mixinName);
        node.save();
        proceeded = true;
      }
    }
    if (proceeded) {
      if (LOG.isDebugEnabled()) {
        LOG.debug("Proceeded node: '{}', nodetype = '{}'", node.getPath(), node.getPrimaryNodeType().getName());
      }
      totalCount++;
    }
    return proceeded;
  }

  private boolean isExceptionalNodeType(Node node, List<String> exceptionalNodeTypes) throws RepositoryException {
    if (exceptionalNodeTypes == null) {
      return false;
    }
    for (String nodeType : exceptionalNodeTypes) {
      if (node.isNodeType(nodeType)) {
        return true;
      }
    }
    return false;
  }

  private UserTransaction beginTransaction() throws Exception {
    UserTransaction transaction;
    transaction = txService.getUserTransaction();
    transaction.setTransactionTimeout(TRANSACTION_TIMEOUT_IN_SECONDS);
    transaction.begin();
    return transaction;
  }

}