[534898] Provide a repository activity log

https://bugs.eclipse.org/bugs/show_bug.cgi?id=534898
diff --git a/plugins/org.eclipse.emf.cdo.examples.installer/examples/org.eclipse.emf.cdo.examples.master/config/cdo-server.xml b/plugins/org.eclipse.emf.cdo.examples.installer/examples/org.eclipse.emf.cdo.examples.master/config/cdo-server.xml
index 1936e4b..e00f297 100644
--- a/plugins/org.eclipse.emf.cdo.examples.installer/examples/org.eclipse.emf.cdo.examples.master/config/cdo-server.xml
+++ b/plugins/org.eclipse.emf.cdo.examples.installer/examples/org.eclipse.emf.cdo.examples.master/config/cdo-server.xml
@@ -42,6 +42,13 @@
 			<initialPackage nsURI="http://www.eclipse.org/emf/CDO/examples/company/1.0.0"/>
     -->
 
+    <!-- Example http://bugs.eclipse.org/534898
+			<activityLog type="rolling">
+        <property name="file" value="/develop/cdo-master/repo1-activities"/>
+        <property name="size" value="100000000"/>
+			</activityLog>
+    -->
+
     <store type="db">
 
       <!-- Example http://bugs.eclipse.org/396379 (if idGenerationLocation == CLIENT)
diff --git a/plugins/org.eclipse.emf.cdo.examples.master/config/cdo-server.xml b/plugins/org.eclipse.emf.cdo.examples.master/config/cdo-server.xml
index 1936e4b..e00f297 100644
--- a/plugins/org.eclipse.emf.cdo.examples.master/config/cdo-server.xml
+++ b/plugins/org.eclipse.emf.cdo.examples.master/config/cdo-server.xml
@@ -42,6 +42,13 @@
 			<initialPackage nsURI="http://www.eclipse.org/emf/CDO/examples/company/1.0.0"/>
     -->
 
+    <!-- Example http://bugs.eclipse.org/534898
+			<activityLog type="rolling">
+        <property name="file" value="/develop/cdo-master/repo1-activities"/>
+        <property name="size" value="100000000"/>
+			</activityLog>
+    -->
+
     <store type="db">
 
       <!-- Example http://bugs.eclipse.org/396379 (if idGenerationLocation == CLIENT)
diff --git a/plugins/org.eclipse.emf.cdo.server.admin/src/org/eclipse/emf/cdo/server/internal/admin/RepositoryConfigurationManagerExtension.java b/plugins/org.eclipse.emf.cdo.server.admin/src/org/eclipse/emf/cdo/server/internal/admin/RepositoryConfigurationManagerExtension.java
index ac9536b..daccf81 100644
--- a/plugins/org.eclipse.emf.cdo.server.admin/src/org/eclipse/emf/cdo/server/internal/admin/RepositoryConfigurationManagerExtension.java
+++ b/plugins/org.eclipse.emf.cdo.server.admin/src/org/eclipse/emf/cdo/server/internal/admin/RepositoryConfigurationManagerExtension.java
@@ -128,6 +128,7 @@
           .getElement(CDORepositoryConfigurationManager.Factory.PRODUCT_GROUP, type, description);
       repoManager.setAdminRepository(repository);
 
+      OM.LOG.info("Admin repository: " + repository.getName());
       return repoManager;
     }
 
diff --git a/plugins/org.eclipse.emf.cdo.server.product/config/cdo-server.xml b/plugins/org.eclipse.emf.cdo.server.product/config/cdo-server.xml
index 9ae1abb..9b8209e 100644
--- a/plugins/org.eclipse.emf.cdo.server.product/config/cdo-server.xml
+++ b/plugins/org.eclipse.emf.cdo.server.product/config/cdo-server.xml
@@ -42,6 +42,13 @@
 			<initialPackage nsURI="http://www.eclipse.org/emf/CDO/examples/company/1.0.0"/>
     -->
 
+    <!-- Example http://bugs.eclipse.org/534898
+			<activityLog type="rolling">
+        <property name="file" value="/develop/cdo-master/repo1-activities"/>
+        <property name="size" value="100000000"/>
+			</activityLog>
+    -->
+
     <store type="db">
 
       <!-- Example http://bugs.eclipse.org/396379 (if idGenerationLocation == CLIENT)
diff --git a/plugins/org.eclipse.emf.cdo.server.security/src/org/eclipse/emf/cdo/server/internal/security/SecurityExtension.java b/plugins/org.eclipse.emf.cdo.server.security/src/org/eclipse/emf/cdo/server/internal/security/SecurityExtension.java
index 49c23fe..6618dfe 100644
--- a/plugins/org.eclipse.emf.cdo.server.security/src/org/eclipse/emf/cdo/server/internal/security/SecurityExtension.java
+++ b/plugins/org.eclipse.emf.cdo.server.security/src/org/eclipse/emf/cdo/server/internal/security/SecurityExtension.java
@@ -128,7 +128,10 @@
       }
 
       String qualifiedDescription = String.format("%s:%s", name, description); //$NON-NLS-1$
-      container.getElement(SecurityManagerFactory.PRODUCT_GROUP, type, qualifiedDescription);
+      if (container.getElement(SecurityManagerFactory.PRODUCT_GROUP, type, qualifiedDescription) != null)
+      {
+        OM.LOG.info("Security manager for repository " + repository.getName() + ": " + qualifiedDescription);
+      }
     }
   }
 
diff --git a/plugins/org.eclipse.emf.cdo.server/plugin.xml b/plugins/org.eclipse.emf.cdo.server/plugin.xml
index f0c3af0..f67d040 100644
--- a/plugins/org.eclipse.emf.cdo.server/plugin.xml
+++ b/plugins/org.eclipse.emf.cdo.server/plugin.xml
@@ -19,6 +19,14 @@
 
 		<extension point="org.eclipse.net4j.util.factories">
       	<factory
+            productGroup="org.eclipse.emf.cdo.server.repositoryConfigurators"
+            type="default"
+            class="org.eclipse.emf.cdo.spi.server.RepositoryConfigurator$Factory$Default"/>
+      	<factory
+            productGroup="org.eclipse.emf.cdo.server.repositoryActivityLogs"
+            type="rolling"
+            class="org.eclipse.emf.cdo.spi.server.RepositoryActivityLog$Rolling$Factory"/>
+      	<factory
             productGroup="org.eclipse.net4j.Negotiators"
             type="challenge"
             class="org.eclipse.net4j.util.security.ChallengeNegotiatorFactory"/>
diff --git a/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/internal/server/bundle/CDOServerApplication.java b/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/internal/server/bundle/CDOServerApplication.java
index 1ffaf53..1a4b660 100644
--- a/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/internal/server/bundle/CDOServerApplication.java
+++ b/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/internal/server/bundle/CDOServerApplication.java
@@ -36,6 +36,10 @@
 {
   public static final String ID = OM.BUNDLE_ID + ".app"; //$NON-NLS-1$
 
+  public static final String PROP_CONFIGURATOR_TYPE = "org.eclipse.emf.cdo.server.repositoryConfiguratorType";
+
+  public static final String PROP_CONFIGURATOR_DESCRIPTION = "org.eclipse.emf.cdo.server.repositoryConfiguratorDescription";
+
   public static final String PROP_BROWSER_PORT = "org.eclipse.emf.cdo.server.browser.port"; //$NON-NLS-1$
 
   private IRepository[] repositories;
@@ -47,6 +51,13 @@
     super(ID);
   }
 
+  protected RepositoryConfigurator getConfigurator(IManagedContainer container)
+  {
+    String type = OMPlatform.INSTANCE.getProperty(PROP_CONFIGURATOR_TYPE, RepositoryConfigurator.Factory.Default.TYPE);
+    String description = OMPlatform.INSTANCE.getProperty(PROP_CONFIGURATOR_DESCRIPTION);
+    return (RepositoryConfigurator)container.getElement(RepositoryConfigurator.Factory.PRODUCT_GROUP, type, description);
+  }
+
   @Override
   protected void doStart() throws Exception
   {
@@ -57,7 +68,8 @@
     File configFile = OMPlatform.INSTANCE.getConfigFile("cdo-server.xml"); //$NON-NLS-1$
     if (configFile != null && configFile.exists())
     {
-      RepositoryConfigurator repositoryConfigurator = new RepositoryConfigurator(container);
+      RepositoryConfigurator repositoryConfigurator = getConfigurator(container);
+
       repositories = repositoryConfigurator.configure(configFile);
       if (repositories == null || repositories.length == 0)
       {
diff --git a/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/spi/server/RepositoryActivityLog.java b/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/spi/server/RepositoryActivityLog.java
new file mode 100644
index 0000000..16fe141
--- /dev/null
+++ b/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/spi/server/RepositoryActivityLog.java
@@ -0,0 +1,293 @@
+/*
+ * Copyright (c) 2004-2018 Eike Stepper (Berlin, Germany) and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *    Eike Stepper - initial API and implementation
+ */
+package org.eclipse.emf.cdo.spi.server;
+
+import org.eclipse.emf.cdo.internal.server.bundle.OM;
+import org.eclipse.emf.cdo.server.IRepository;
+import org.eclipse.emf.cdo.server.IRepository.WriteAccessHandler;
+import org.eclipse.emf.cdo.server.ISession;
+import org.eclipse.emf.cdo.server.IStoreAccessor.CommitContext;
+import org.eclipse.emf.cdo.server.ITransaction;
+import org.eclipse.emf.cdo.server.IView;
+
+import org.eclipse.net4j.util.StringUtil;
+import org.eclipse.net4j.util.container.ContainerEventAdapter;
+import org.eclipse.net4j.util.container.IContainer;
+import org.eclipse.net4j.util.event.IListener;
+import org.eclipse.net4j.util.factory.ProductCreationException;
+import org.eclipse.net4j.util.lifecycle.LifecycleHook;
+import org.eclipse.net4j.util.om.log.Log;
+import org.eclipse.net4j.util.om.log.RollingLog;
+import org.eclipse.net4j.util.om.monitor.OMMonitor;
+
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * @author Eike Stepper
+ * @since 4.7
+ */
+public abstract class RepositoryActivityLog extends LifecycleHook<IRepository> implements Log
+{
+  private final IListener sessionManagerListener = new ContainerEventAdapter<ISession>()
+  {
+    @Override
+    protected void onAdded(IContainer<ISession> container, ISession session)
+    {
+      sessionOpened(session, concurrentSessions.incrementAndGet(), sessions.incrementAndGet());
+      session.addListener(sessionListener);
+    }
+
+    @Override
+    protected void onRemoved(IContainer<ISession> container, ISession session)
+    {
+      session.removeListener(sessionListener);
+      sessionClosed(session, concurrentSessions.decrementAndGet());
+    }
+  };
+
+  private final IListener sessionListener = new ContainerEventAdapter<IView>()
+  {
+    @Override
+    protected void onAdded(IContainer<IView> container, IView view)
+    {
+      if (view instanceof ITransaction)
+      {
+        transactionOpened((ITransaction)view, concurrentTransactions.incrementAndGet(), transactions.incrementAndGet());
+      }
+      else
+      {
+        viewOpened(view, concurrentViews.incrementAndGet(), views.incrementAndGet());
+      }
+    }
+
+    @Override
+    protected void onRemoved(IContainer<IView> container, IView view)
+    {
+      if (view instanceof ITransaction)
+      {
+        transactionClosed((ITransaction)view, concurrentTransactions.decrementAndGet());
+      }
+      else
+      {
+        viewClosed(view, concurrentViews.decrementAndGet());
+      }
+    }
+  };
+
+  private final WriteAccessHandler writeAccessHandler = new WriteAccessHandler()
+  {
+    public void handleTransactionBeforeCommitting(ITransaction transaction, CommitContext commitContext, OMMonitor monitor) throws RuntimeException
+    {
+      commitStarted(commitContext, concurrentCommits.incrementAndGet(), commits.incrementAndGet());
+    }
+
+    public void handleTransactionAfterCommitted(ITransaction transaction, CommitContext commitContext, OMMonitor monitor)
+    {
+      commitFinished(commitContext, concurrentCommits.decrementAndGet());
+    }
+  };
+
+  private final AtomicInteger sessions = new AtomicInteger();
+
+  private final AtomicInteger views = new AtomicInteger();
+
+  private final AtomicInteger transactions = new AtomicInteger();
+
+  private final AtomicInteger commits = new AtomicInteger();
+
+  private final AtomicInteger concurrentSessions = new AtomicInteger();
+
+  private final AtomicInteger concurrentViews = new AtomicInteger();
+
+  private final AtomicInteger concurrentTransactions = new AtomicInteger();
+
+  private final AtomicInteger concurrentCommits = new AtomicInteger();
+
+  public RepositoryActivityLog()
+  {
+  }
+
+  public IRepository getRepository()
+  {
+    return getDelegate();
+  }
+
+  public void setRepository(IRepository repository)
+  {
+    setDelegate(repository);
+  }
+
+  protected void sessionOpened(ISession session, int concurrentSessions, int sessions)
+  {
+    log(formatSession(session) + " opened" + formatUser(session) + " (" + concurrentSessions + "/" + sessions + ")");
+  }
+
+  protected void sessionClosed(ISession session, int concurrentSessions)
+  {
+    log(formatSession(session) + " closed" + formatUser(session) + " (" + concurrentSessions + ")");
+  }
+
+  protected void viewOpened(IView view, int concurrentViews, int views)
+  {
+    log(formatView(view) + " opened" + formatUser(view.getSession()) + " (" + concurrentViews + "/" + views + ")");
+  }
+
+  protected void viewClosed(IView view, int concurrentViews)
+  {
+    log(formatView(view) + " closed" + formatUser(view.getSession()) + " (" + concurrentViews + ")");
+  }
+
+  protected void transactionOpened(ITransaction transaction, int concurrentTransactions, int transactions)
+  {
+    log(formatView(transaction) + " opened" + formatUser(transaction.getSession()) + " (" + concurrentTransactions + "/" + transactions + ")");
+  }
+
+  protected void transactionClosed(ITransaction transaction, int concurrentTransactions)
+  {
+    log(formatView(transaction) + " closed" + formatUser(transaction.getSession()) + " (" + concurrentTransactions + ")");
+  }
+
+  protected void commitStarted(CommitContext commitContext, int concurrentCommits, int commits)
+  {
+    ITransaction transaction = commitContext.getTransaction();
+    log(formatView(transaction) + " committing " + commitContext.getBranchPoint().getTimeStamp() + formatUser(transaction.getSession()) + " ("
+        + concurrentCommits + "/" + commits + ")");
+  }
+
+  protected void commitFinished(CommitContext commitContext, int concurrentCommits)
+  {
+    ITransaction transaction = commitContext.getTransaction();
+    log(formatView(transaction) + (commitContext.getRollbackMessage() != null ? " committed " : " rolled back ") + commitContext.getBranchPoint().getTimeStamp()
+        + formatUser(transaction.getSession()) + " (" + concurrentCommits + ")");
+  }
+
+  protected String formatSession(ISession session)
+  {
+    return "Session " + session.getSessionID();
+  }
+
+  protected String formatUser(ISession session)
+  {
+    String userID = session.getUserID();
+    return StringUtil.isEmpty(userID) ? "" : " by user " + userID;
+  }
+
+  protected String formatView(IView view)
+  {
+    return (view instanceof ITransaction ? "Transaction " : "View ") + view.getSessionID() + ":" + view.getViewID();
+  }
+
+  @Override
+  protected void hookDelegate(IRepository repository)
+  {
+    repository.getSessionManager().addListener(sessionManagerListener);
+    repository.addHandler(writeAccessHandler);
+  }
+
+  @Override
+  protected void unhookDelegate(IRepository repository)
+  {
+    repository.removeHandler(writeAccessHandler);
+    repository.getSessionManager().removeListener(sessionManagerListener);
+  }
+
+  /**
+   * @author Eike Stepper
+   * @since 4.7
+   */
+  public static abstract class Factory extends org.eclipse.net4j.util.factory.PropertiesFactory
+  {
+    public static final String PRODUCT_GROUP = "org.eclipse.emf.cdo.server.repositoryActivityLogs"; //$NON-NLS-1$
+
+    public Factory(String type)
+    {
+      super(PRODUCT_GROUP, type);
+    }
+
+    @Override
+    protected abstract RepositoryActivityLog create(Map<String, String> properties) throws ProductCreationException;
+  }
+
+  /**
+   * @author Eike Stepper
+   * @since 4.7
+   */
+  public static class Rolling extends RepositoryActivityLog
+  {
+    private final RollingLog rollingLog;
+
+    public Rolling(String logFile, long logSize)
+    {
+      rollingLog = new RollingLog(logFile, logSize);
+    }
+
+    public void log(String line)
+    {
+      rollingLog.log(line);
+    }
+
+    @Override
+    protected void delegateChanged(IRepository oldRepository, IRepository newRepository)
+    {
+      if (newRepository != null)
+      {
+        OM.LOG.info("Logging activities of repository " + newRepository.getName() + " to " + rollingLog.getLogFile());
+      }
+
+    }
+
+    @Override
+    protected void doActivate() throws Exception
+    {
+      rollingLog.activate();
+      super.doActivate();
+    }
+
+    @Override
+    protected void doDeactivate() throws Exception
+    {
+      super.doDeactivate();
+      rollingLog.deactivate();
+    }
+
+    /**
+     * @author Eike Stepper
+     */
+    public static final class Factory extends RepositoryActivityLog.Factory
+    {
+      public static final String TYPE = "rolling"; //$NON-NLS-1$
+
+      public Factory()
+      {
+        super(TYPE);
+      }
+
+      @Override
+      protected RepositoryActivityLog create(Map<String, String> properties) throws ProductCreationException
+      {
+        String file = properties.get("file"); //$NON-NLS-1$
+        if (file == null)
+        {
+          file = "activities";
+        }
+
+        String size = properties.get("size"); //$NON-NLS-1$
+        if (StringUtil.isEmpty(size))
+        {
+          size = "100000000";
+        }
+
+        return new RepositoryActivityLog.Rolling(file, Long.parseLong(size));
+      }
+    }
+  }
+}
diff --git a/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/spi/server/RepositoryConfigurator.java b/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/spi/server/RepositoryConfigurator.java
index b9f206a..07450bc 100644
--- a/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/spi/server/RepositoryConfigurator.java
+++ b/plugins/org.eclipse.emf.cdo.server/src/org/eclipse/emf/cdo/spi/server/RepositoryConfigurator.java
@@ -23,6 +23,9 @@
 import org.eclipse.net4j.util.ObjectUtil;
 import org.eclipse.net4j.util.StringUtil;
 import org.eclipse.net4j.util.container.IManagedContainer;
+import org.eclipse.net4j.util.container.IManagedContainer.ContainerAware;
+import org.eclipse.net4j.util.factory.ProductCreationException;
+import org.eclipse.net4j.util.factory.PropertiesFactory;
 import org.eclipse.net4j.util.om.OMPlatform;
 import org.eclipse.net4j.util.om.trace.ContextTracer;
 import org.eclipse.net4j.util.security.AuthenticatorFactory;
@@ -136,6 +139,11 @@
       if (container != null)
       {
         CDOServerUtil.addRepository(container, repository);
+        OM.LOG.info("CDO repository " + repository.getName() + " started");
+      }
+      else
+      {
+        OM.LOG.info("CDO repository " + repository.getName() + " added");
       }
     }
 
@@ -207,6 +215,7 @@
 
     setUserManager(repository, repositoryConfig);
     setAuthenticator(repository, repositoryConfig);
+    setActivityLog(repository, repositoryConfig);
 
     EPackage[] initialPackages = getInitialPackages(repositoryConfig);
     if (initialPackages.length != 0)
@@ -339,6 +348,27 @@
     }
   }
 
+  /**
+   * @since 4.7
+   */
+  protected void setActivityLog(InternalRepository repository, Element repositoryConfig)
+  {
+    NodeList activityLogConfig = repositoryConfig.getElementsByTagName("activityLog"); //$NON-NLS-1$
+    if (activityLogConfig.getLength() > 1)
+    {
+      String repositoryName = repositoryConfig.getAttribute("name"); //$NON-NLS-1$
+      throw new IllegalStateException("At most one activity log must be configured for repository " + repositoryName); //$NON-NLS-1$
+    }
+
+    if (activityLogConfig.getLength() > 0)
+    {
+      Element activityLogElement = (Element)activityLogConfig.item(0);
+
+      RepositoryActivityLog activityLog = getContainerElement(activityLogElement, RepositoryActivityLog.Rolling.Factory.TYPE);
+      activityLog.setRepository(repository);
+    }
+  }
+
   protected EPackage[] getInitialPackages(Element repositoryConfig)
   {
     List<EPackage> result = new ArrayList<EPackage>();
@@ -400,6 +430,30 @@
     return storeFactory.createStore(repositoryName, repositoryProperties, storeConfig);
   }
 
+  /**
+   * @since 4.7
+   */
+  protected <T> T getContainerElement(Element element, String defaultType)
+  {
+    String type = element.getAttribute("type"); //$NON-NLS-1$
+    if (StringUtil.isEmpty(type))
+    {
+      type = defaultType;
+    }
+  
+    String description = element.getAttribute("description"); //$NON-NLS-1$
+    if (StringUtil.isEmpty(description))
+    {
+      Map<String, String> properties = getProperties(element, 1);
+      description = PropertiesFactory.createDescription(properties);
+    }
+  
+    @SuppressWarnings("unchecked")
+    T containerElement = (T)container.getElement(RepositoryActivityLog.Factory.PRODUCT_GROUP, type, description);
+  
+    return containerElement;
+  }
+
   public static Map<String, String> getProperties(Element element, int levels)
   {
     Map<String, String> properties = new HashMap<String, String>();
@@ -454,4 +508,51 @@
 
     return null;
   }
+
+  /**
+   * @author Eike Stepper
+   * @since 4.7
+   */
+  public static abstract class Factory extends org.eclipse.net4j.util.factory.Factory implements ContainerAware
+  {
+    public static final String PRODUCT_GROUP = "org.eclipse.emf.cdo.server.repositoryConfigurators"; //$NON-NLS-1$
+
+    private IManagedContainer container;
+
+    public Factory(String type)
+    {
+      super(PRODUCT_GROUP, type);
+    }
+
+    public void setManagedContainer(IManagedContainer container)
+    {
+      this.container = container;
+    }
+
+    public final RepositoryConfigurator create(String description) throws ProductCreationException
+    {
+      return create(container, description);
+    }
+
+    public abstract RepositoryConfigurator create(IManagedContainer container, String description) throws ProductCreationException;
+
+    /**
+     * @author Eike Stepper
+     */
+    public static final class Default extends Factory
+    {
+      public static final String TYPE = "default"; //$NON-NLS-1$
+
+      public Default()
+      {
+        super(TYPE);
+      }
+
+      @Override
+      public RepositoryConfigurator create(IManagedContainer container, String description) throws ProductCreationException
+      {
+        return new RepositoryConfigurator(container);
+      }
+    }
+  }
 }
diff --git a/plugins/org.eclipse.net4j.util/plugin.xml b/plugins/org.eclipse.net4j.util/plugin.xml
index 73d6d7c..31b1dcc 100644
--- a/plugins/org.eclipse.net4j.util/plugin.xml
+++ b/plugins/org.eclipse.net4j.util/plugin.xml
@@ -42,8 +42,7 @@
       <factory
             productGroup="org.eclipse.net4j.util.confirmationProviders"
             type="default"
-            class="org.eclipse.net4j.util.confirmation.IConfirmationProvider.Factory.Default">
-      </factory>
+            class="org.eclipse.net4j.util.confirmation.IConfirmationProvider.Factory.Default"/>
    </extension>
 
 </plugin>
diff --git a/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/concurrent/ConcurrencyUtil.java b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/concurrent/ConcurrencyUtil.java
index 0ec46b6..7d6ef1f 100644
--- a/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/concurrent/ConcurrencyUtil.java
+++ b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/concurrent/ConcurrencyUtil.java
@@ -115,4 +115,12 @@
     thread.setDaemon(true);
     thread.start();
   }
+
+  /**
+   * @since 3.8
+   */
+  public static void setThreadName(Thread thread, String name)
+  {
+    thread.setName(name);
+  }
 }
diff --git a/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/concurrent/RunnableWithName.java b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/concurrent/RunnableWithName.java
index 54feb65..4bedf23 100644
--- a/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/concurrent/RunnableWithName.java
+++ b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/concurrent/RunnableWithName.java
@@ -44,7 +44,7 @@
       }
       else
       {
-        thread.setName(name);
+        ConcurrencyUtil.setThreadName(thread, name);
       }
     }
 
@@ -56,7 +56,7 @@
     {
       if (thread != null)
       {
-        thread.setName(oldName);
+        ConcurrencyUtil.setThreadName(thread, oldName);
       }
     }
   }
diff --git a/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/factory/PropertiesFactory.java b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/factory/PropertiesFactory.java
new file mode 100644
index 0000000..f8e18ec
--- /dev/null
+++ b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/factory/PropertiesFactory.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2018 Eike Stepper (Berlin, Germany) and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *    Eike Stepper - initial API and implementation
+ */
+package org.eclipse.net4j.util.factory;
+
+import org.eclipse.net4j.util.StringUtil;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author Eike Stepper
+ * @since 3.8
+ */
+public abstract class PropertiesFactory extends Factory
+{
+  public static final String PROPERTY_SEPARATOR = "|";
+
+  public static final String DEFAULT_KEY = "_";
+
+  public PropertiesFactory(FactoryKey key)
+  {
+    super(key);
+  }
+
+  public PropertiesFactory(String productGroup, String type)
+  {
+    super(productGroup, type);
+  }
+
+  public Object create(String description) throws ProductCreationException
+  {
+    Map<String, String> properties = new HashMap<String, String>();
+
+    if (!StringUtil.isEmpty(description))
+    {
+      String[] segments = description.split("\\" + PROPERTY_SEPARATOR);
+      for (String segment : segments)
+      {
+        if (!StringUtil.isEmpty(segment))
+        {
+          int pos = segment.indexOf('=');
+          if (pos != -1)
+          {
+            String key = segment.substring(0, pos).trim();
+            String value = segment.substring(pos + 1).trim();
+            properties.put(key, value);
+          }
+          else
+          {
+            properties.put(DEFAULT_KEY, segment);
+          }
+        }
+      }
+    }
+
+    return create(properties);
+  }
+
+  protected abstract Object create(Map<String, String> properties) throws ProductCreationException;
+
+  public static String createDescription(Map<String, String> properties)
+  {
+    StringBuilder builder = new StringBuilder();
+
+    String defaultValue = properties.remove(DEFAULT_KEY);
+    if (!StringUtil.isEmpty(defaultValue))
+    {
+      builder.append(defaultValue);
+    }
+
+    for (Map.Entry<String, String> entry : properties.entrySet())
+    {
+      if (builder.length() != 0)
+      {
+        builder.append(PROPERTY_SEPARATOR);
+      }
+
+      builder.append(entry.getKey());
+      builder.append("=");
+      builder.append(entry.getValue());
+    }
+
+    return builder.toString();
+  }
+}
diff --git a/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/lifecycle/LifecycleHook.java b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/lifecycle/LifecycleHook.java
new file mode 100644
index 0000000..07a57b3
--- /dev/null
+++ b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/lifecycle/LifecycleHook.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2018 Eike Stepper (Berlin, Germany) and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *    Eike Stepper - initial API and implementation
+ */
+package org.eclipse.net4j.util.lifecycle;
+
+import org.eclipse.net4j.util.event.IEvent;
+import org.eclipse.net4j.util.event.IListener;
+
+/**
+ * @author Eike Stepper
+ * @since 3.8
+ */
+public class LifecycleHook<T extends ILifecycle> extends Lifecycle
+{
+  private final IListener delegateListener = new LifecycleEventAdapter()
+  {
+    @Override
+    protected void notifyOtherEvent(IEvent event)
+    {
+      delegateEvent(delegate, event);
+    }
+
+    @Override
+    protected void onAboutToActivate(ILifecycle lifecycle)
+    {
+      delegateAboutToActivate(delegate);
+    }
+
+    @Override
+    protected void onAboutToDeactivate(ILifecycle lifecycle)
+    {
+      delegateAboutToDeactivate(delegate);
+    }
+
+    @Override
+    protected void onActivated(ILifecycle lifecycle)
+    {
+      delegateActivated(delegate);
+      hookDelegateIfPossible();
+    }
+
+    @Override
+    protected void onDeactivated(ILifecycle lifecycle)
+    {
+      unhookDelegateIfPossible();
+      delegateDeactivated(delegate);
+    }
+  };
+
+  private T delegate;
+
+  private boolean listening;
+
+  private boolean delegateHooked;
+
+  public LifecycleHook()
+  {
+  }
+
+  protected final T getDelegate()
+  {
+    return delegate;
+  }
+
+  protected final void setDelegate(T delegate)
+  {
+    T oldDelegate = this.delegate;
+
+    if (oldDelegate != delegate)
+    {
+      unhookDelegateIfPossible();
+      this.delegate = delegate;
+      hookDelegateIfPossible();
+
+      delegateChanged(oldDelegate, delegate);
+    }
+  }
+
+  protected void delegateChanged(T oldDelegate, T newDelegate)
+  {
+  }
+
+  protected void delegateEvent(T delegate, IEvent event)
+  {
+  }
+
+  protected void delegateAboutToActivate(T delegate)
+  {
+  }
+
+  protected void delegateActivated(T delegate)
+  {
+  }
+
+  protected void delegateAboutToDeactivate(T delegate)
+  {
+  }
+
+  protected void delegateDeactivated(T delegate)
+  {
+  }
+
+  @Override
+  protected void doActivate() throws Exception
+  {
+    hookDelegateIfPossible();
+  }
+
+  @Override
+  protected void doDeactivate() throws Exception
+  {
+    unhookDelegateIfPossible();
+  }
+
+  protected void hookDelegate(T delegate)
+  {
+  }
+
+  protected void unhookDelegate(T delegate)
+  {
+  }
+
+  protected boolean hookInactiveDelegates()
+  {
+    return false;
+  }
+
+  private void hookDelegateIfPossible()
+  {
+    if (!listening && delegate != null)
+    {
+      delegate.addListener(delegateListener);
+      listening = true;
+    }
+
+    if (listening && !delegateHooked && (hookInactiveDelegates() || delegate.isActive()))
+    {
+      hookDelegate(delegate);
+      delegateHooked = true;
+    }
+  }
+
+  private void unhookDelegateIfPossible()
+  {
+    if (delegate != null)
+    {
+      if (delegateHooked)
+      {
+        unhookDelegate(delegate);
+        delegateHooked = false;
+      }
+
+      if (listening)
+      {
+        delegate.removeListener(delegateListener);
+        listening = false;
+      }
+    }
+  }
+}
diff --git a/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/om/log/Log.java b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/om/log/Log.java
new file mode 100644
index 0000000..ec858c7
--- /dev/null
+++ b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/om/log/Log.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2018 Eike Stepper (Berlin, Germany) and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *    Eike Stepper - initial API and implementation
+ */
+package org.eclipse.net4j.util.om.log;
+
+/**
+ * @author Eike Stepper
+ * @since 3.8
+ */
+public interface Log
+{
+  public void log(String line);
+}
diff --git a/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/om/log/RollingLog.java b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/om/log/RollingLog.java
new file mode 100644
index 0000000..011f1e5
--- /dev/null
+++ b/plugins/org.eclipse.net4j.util/src/org/eclipse/net4j/util/om/log/RollingLog.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright (c) 2018 Eike Stepper (Berlin, Germany) and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *    Eike Stepper - initial API and implementation
+ */
+package org.eclipse.net4j.util.om.log;
+
+import org.eclipse.net4j.internal.util.bundle.OM;
+import org.eclipse.net4j.util.collection.AbstractIterator;
+import org.eclipse.net4j.util.collection.CloseableIterator;
+import org.eclipse.net4j.util.concurrent.Worker;
+import org.eclipse.net4j.util.io.IOUtil;
+import org.eclipse.net4j.util.om.log.RollingLog.LogLine;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.StringTokenizer;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * @author Eike Stepper
+ * @since 3.8
+ */
+public class RollingLog extends Worker implements Log, Iterable<LogLine>
+{
+  private final String logFile;
+
+  private final long logSize;
+
+  private final AtomicLong logLineCounter = new AtomicLong(0);
+
+  private int fileNumber;
+
+  private boolean fileAppend;
+
+  private List<LogLine> queue = new ArrayList<LogLine>();
+
+  public RollingLog(String logFile, long logSize)
+  {
+    this.logFile = logFile;
+    this.logSize = logSize;
+
+    setDaemon(true);
+  }
+
+  public final String getLogFile()
+  {
+    return logFile;
+  }
+
+  public final long getLogSize()
+  {
+    return logSize;
+  }
+
+  public final void log(String line)
+  {
+    LogLine logLine = createLogLine(line);
+
+    synchronized (this)
+    {
+      logLine.id = logLineCounter.incrementAndGet();
+      queue.add(logLine);
+      notifyAll();
+    }
+  }
+
+  @Override
+  protected final void work(WorkContext context) throws Exception
+  {
+    List<LogLine> logLines;
+    synchronized (this)
+    {
+      if (queue.isEmpty())
+      {
+        try
+        {
+          wait(100);
+        }
+        catch (InterruptedException ex)
+        {
+          context.terminate();
+        }
+
+        context.nextWork();
+      }
+
+      logLines = queue;
+      queue = new ArrayList<LogLine>();
+    }
+
+    writeLogLines(logLines);
+  }
+
+  protected LogLine createLogLine(String line)
+  {
+    long millis = System.currentTimeMillis();
+    String thread = getThreadInfo();
+
+    return new LogLine(millis, thread, line);
+  }
+
+  protected void writeLogLines(List<LogLine> logLines)
+  {
+    if (logFile != null)
+    {
+      PrintStream out = null;
+
+      try
+      {
+        File file;
+
+        for (;;)
+        {
+          file = getFile(logFile, fileNumber);
+
+          if (fileAppend && file.length() > logSize)
+          {
+            fileNumber++;
+            fileAppend = false;
+            continue;
+          }
+
+          break;
+        }
+
+        out = new PrintStream(new FileOutputStream(file, fileAppend));
+        writeLogLines(logLines, out);
+        out.close();
+      }
+      catch (IOException ex)
+      {
+        OM.LOG.error(ex);
+      }
+      finally
+      {
+        fileAppend = true;
+        IOUtil.closeSilent(out);
+      }
+    }
+    else
+    {
+      writeLogLines(logLines, System.out);
+    }
+  }
+
+  protected void writeLogLines(List<LogLine> logLines, PrintStream out)
+  {
+    for (LogLine logLine : logLines)
+    {
+      writeLogLine(logLine, out);
+    }
+  }
+
+  protected void writeLogLine(LogLine logLine, PrintStream out)
+  {
+    out.println(logLine);
+  }
+
+  protected String getThreadInfo()
+  {
+    return Thread.currentThread().getName();
+  }
+
+  public final CloseableIterator<LogLine> iterator()
+  {
+    return iterator(logFile);
+  }
+
+  public static CloseableIterator<LogLine> iterator(String logFile)
+  {
+    return new LogIterator(logFile);
+  }
+
+  private static File getFile(String logFile, int fileNumber)
+  {
+    return new File(logFile + String.format("-%04d", fileNumber) + ".txt");
+  }
+
+  /**
+   * @author Eike Stepper
+   */
+  private static final class LogIterator extends AbstractIterator<LogLine> implements CloseableIterator<LogLine>
+  {
+    private static final int CLOSED = -1;
+
+    private final String logFile;
+
+    private int fileNumber;
+
+    private BufferedReader reader;
+
+    public LogIterator(String logFile)
+    {
+      this.logFile = logFile;
+    }
+
+    @Override
+    protected Object computeNextElement()
+    {
+      if (fileNumber == CLOSED)
+      {
+        return END_OF_DATA;
+      }
+
+      if (reader == null)
+      {
+        File file = getFile(logFile, fileNumber++);
+        if (file.isFile())
+        {
+          try
+          {
+            reader = new BufferedReader(new FileReader(file));
+          }
+          catch (FileNotFoundException ex)
+          {
+            OM.LOG.error(ex);
+            return END_OF_DATA;
+          }
+        }
+        else
+        {
+          return END_OF_DATA;
+        }
+      }
+
+      try
+      {
+        String string = reader.readLine();
+        if (string == null)
+        {
+          reader.close();
+          reader = null;
+          return computeNextElement();
+        }
+
+        return new LogLine(string);
+      }
+      catch (IOException ex)
+      {
+        OM.LOG.error(ex);
+        return END_OF_DATA;
+      }
+    }
+
+    public void close()
+    {
+      IOUtil.close(reader);
+      reader = null;
+      fileNumber = CLOSED;
+    }
+
+    public boolean isClosed()
+    {
+      return fileNumber == CLOSED;
+    }
+  }
+
+  /**
+   * @author Eike Stepper
+   */
+  public static final class LogLine
+  {
+    private static final String TAB = "\t";
+
+    private long id;
+
+    private final long millis;
+
+    private final String thread;
+
+    private final String line;
+
+    public LogLine(long millis, String thread, String line)
+    {
+      this.millis = millis;
+      this.thread = thread;
+      this.line = line;
+    }
+
+    public LogLine(String string)
+    {
+      StringTokenizer tokenizer = new StringTokenizer(string, TAB);
+      id = Long.parseLong(tokenizer.nextToken());
+      millis = Long.parseLong(tokenizer.nextToken());
+      thread = tokenizer.nextToken();
+      line = tokenizer.nextToken("").substring(1);
+    }
+
+    public long getID()
+    {
+      return id;
+    }
+
+    public long getMillis()
+    {
+      return millis;
+    }
+
+    public String getThread()
+    {
+      return thread;
+    }
+
+    public String getLine()
+    {
+      return line;
+    }
+
+    @Override
+    public String toString()
+    {
+      return id + TAB + millis + TAB + thread + TAB + line;
+    }
+  }
+}