Index: oak-core/src/main/java/org/apache/jackrabbit/oak/core/SimpleCommitContext.java
===================================================================
--- oak-core/src/main/java/org/apache/jackrabbit/oak/core/SimpleCommitContext.java	(revision 1763448)
+++ oak-core/src/main/java/org/apache/jackrabbit/oak/core/SimpleCommitContext.java	(working copy)
@@ -30,6 +30,11 @@
     private final Map<String, Object> attrs = Maps.newHashMap();
 
     @Override
+    public String toString() {
+    	return "CommitContext[attrs="+attrs+"]";
+    }
+    
+    @Override
     public void set(String name, Object value) {
         attrs.put(checkNotNull(name), value);
     }
Index: oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/observation/ChangeCollectorProvider.java
===================================================================
--- oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/observation/ChangeCollectorProvider.java	(revision 0)
+++ oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/observation/ChangeCollectorProvider.java	(working copy)
@@ -0,0 +1,306 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.jackrabbit.oak.plugins.observation;
+
+import static org.apache.jackrabbit.oak.commons.PathUtils.concat;
+import static org.apache.jackrabbit.oak.commons.PathUtils.getName;
+import static org.apache.jackrabbit.oak.commons.PropertiesUtil.toInteger;
+
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.spi.commit.CommitContext;
+import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
+import org.apache.jackrabbit.oak.spi.commit.Validator;
+import org.apache.jackrabbit.oak.spi.commit.ValidatorProvider;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.osgi.service.component.ComponentContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.collect.Lists;
+
+/**
+ * A ChangeCollectorProvider can be hooked into Oak thus enabling the collection
+ * of ChangeSets of changed items of a commit, which downstream Observers can 
+ * then use at their convenience.
+ * <p>
+ * @see ChangeSet for details on what is tracked and how that data should be
+ * interpreted
+ */
+@Component(immediate = true)
+@Property(name = "type", value = "changeCollectorProvider", propertyPrivate = true)
+@Service(ValidatorProvider.class)
+public class ChangeCollectorProvider extends ValidatorProvider {
+
+    private static final Logger LOG = LoggerFactory.getLogger(ChangeCollectorProvider.class);
+
+    public static final String COMMIT_CONTEXT_OBSERVATION_CHANGESET = "oak.observation.changeSet";
+
+    private static final int DEFAULT_MAX_ITEMS = 50;
+    @Property(longValue = DEFAULT_MAX_ITEMS,
+            label = "Maximum Number of Collected Items (per type)",
+            description = "Integer value indicating maximum number of individual items of changes - "
+                    + "such as property, nodeType, node name, path - to be collected. If there are "
+                    + "more changes, the collection is considered failed and marked as such. "
+                    + "Default is " + DEFAULT_MAX_ITEMS
+    )
+    private static final String PROP_MAX_ITEMS = "maxItems";
+
+    private static final int DEFAULT_MAX_PATH_DEPTH = 9;
+    @Property(longValue = DEFAULT_MAX_PATH_DEPTH,
+            label = "Maximum depth of paths to collect",
+            description = "Integer value indicating maximum depth of paths to collect. "
+            		+ "Paths deeper than this will not be individually reported, and instead "
+            		+ "a path at this max depth will be added. Note that this doesn't affect "
+            		+ "any other collected item such as property, nodeType - ie those will "
+            		+ "all be collected irrespective of this config param."
+                    + "Default is " + DEFAULT_MAX_PATH_DEPTH
+    )
+    private static final String PROP_MAX_PATH_DEPTH = "maxPathDepth";
+
+    /**
+     * There is one CollectorSupport per validation process - it is shared
+     * between multiple instances of ChangeCollector (Validator) - however
+     * it can remain unsynchronized as validators are executed single-threaded.
+     */
+    private static class CollectorSupport {
+		private final CommitInfo info;
+		private final int maxPathDepth;
+		private final ChangeSet changeSet;
+		
+		private CollectorSupport(@Nonnull CommitInfo info, @Nonnull ChangeSet changeSet, int maxPathDepth) {
+			this.info = info;
+			this.changeSet = changeSet;
+			this.maxPathDepth = maxPathDepth;
+		}
+		
+		@Override
+		public String toString() {
+		    return "CollectorSupport with " + changeSet;
+		}
+		
+		private CommitInfo getInfo() {
+			return info;
+		}
+		
+		private int getMaxPathDepth() {
+			return maxPathDepth;
+		}
+		
+		private ChangeSet getChangeSet() {
+			return changeSet;
+		}
+
+		private Set<String> getParentPaths() {
+			return changeSet.getParentPaths();
+		}
+
+		private Set<String> getParentNodeNames() {
+			return changeSet.getParentNodeNames();
+		}
+		
+		private Set<String> getParentNodeTypes() {
+			return changeSet.getParentNodeTypes();
+		}
+
+		private Set<String> getPropertyNames() {
+			return changeSet.getPropertyNames();
+		}
+    }
+    
+    /**
+     * ChangeCollectors are the actual working-horse Validators that
+     * are created for each level thus as a whole propage through 
+     * the entire change.
+     * <p>
+     * The actual data is collected via a per-commit CollectorSupport
+     * and its underlying ChangeSet (the latter is where the actual
+     * changes end up in).
+     * <p>
+     * When finished - ie in the last==root leave() - the resulting
+     * ChangeSet is marked immutable and set in the CommitContext.
+     */
+    private static class ChangeCollector implements Validator {
+
+    	private final CollectorSupport support;
+
+    	private final boolean isRoot;
+		private final NodeState parentNodeOrNull;
+		private final String path;
+		private final int level;
+
+		private boolean addParentPathOnLeave;
+		private boolean addParentNodeNameOnLeave;
+		private boolean addParentNodeTypeOnLeave;
+
+		private static ChangeCollector newRootCollector(@Nonnull CommitInfo info, int maxItems, int maxPathDepth) {
+			ChangeSet changeSet = new ChangeSet(maxItems, maxPathDepth);
+			CollectorSupport support = new CollectorSupport(info, changeSet, maxPathDepth);
+			return new ChangeCollector(support, true, null, "/", 0);
+		}
+		
+		private ChangeCollector newChildCollector(@Nonnull NodeState parentNode, @Nonnull String childName) {
+			return new ChangeCollector(support, false, parentNode, concat(path, childName), level + 1);
+		}
+		
+		private ChangeCollector(@Nonnull CollectorSupport support, boolean isRoot, NodeState parentNodeOrNull, @Nonnull String path, int level) {
+			this.support = support;
+			this.isRoot = isRoot;
+			this.parentNodeOrNull = parentNodeOrNull;
+			this.path = path;
+			this.level = level;
+		}
+		
+		@Override
+		public String toString() {
+			return "ChangeCollector[path="+path+"]";
+		}
+
+		@Override
+		public void enter(NodeState before, NodeState after) throws CommitFailedException {
+			// nothing to be done here
+		}
+
+		@Override
+		public void leave(NodeState before, NodeState after) throws CommitFailedException {
+			// first check if we have to add anything to paths and/or nodeNames
+			if (addParentPathOnLeave && level <= support.getMaxPathDepth()) {
+				support.getParentPaths().add(path);
+			}
+			if (addParentNodeNameOnLeave) {
+				support.getParentNodeNames().add(getName(path));
+			}
+			if (addParentNodeTypeOnLeave && parentNodeOrNull != null) {
+				String primaryType = parentNodeOrNull.getName("jcr:primaryType");
+				if (primaryType != null) {
+					support.getParentNodeTypes().add(primaryType);
+				}
+				support.getParentNodeTypes().addAll(Lists.newArrayList(parentNodeOrNull.getNames("jcr:mixinTypes")));
+			}
+
+			// then if we're not at the root, we're done
+			if (!isRoot) {
+				return;
+			}
+
+			// but if we're at the root, then we add the ChangeSet to the CommitContext of the CommitInfo
+			CommitContext commitContext = (CommitContext) support.getInfo().getInfo().get(CommitContext.NAME);
+			commitContext.set(COMMIT_CONTEXT_OBSERVATION_CHANGESET, support.getChangeSet().asImmutable());
+		}
+		
+		@Override
+		public void propertyAdded(PropertyState after) throws CommitFailedException {
+			support.getPropertyNames().add(after.getName());
+			// record a change at the current path (which is the parent of this property)
+			addParentPathOnLeave = true;
+			// and with the current node name
+			addParentNodeNameOnLeave = true;
+			// additionally, add parent's nodeType on leave too
+			addParentNodeTypeOnLeave = true;
+		}
+
+		@Override
+		public void propertyChanged(PropertyState before, PropertyState after) throws CommitFailedException {
+			support.getPropertyNames().add(before.getName());
+			// record a change at the current path (which is the parent of this property)
+			addParentPathOnLeave = true;
+			// and with the current node name
+			addParentNodeNameOnLeave = true;
+			// additionally, add parent's nodeType on leave too
+			addParentNodeTypeOnLeave = true;
+		}
+
+		@Override
+		public void propertyDeleted(PropertyState before) throws CommitFailedException {
+			support.getPropertyNames().add(before.getName());
+			// record a change at the current path (which is the parent of this property)
+			addParentPathOnLeave = true;
+			// and with the current node name
+			addParentNodeNameOnLeave = true;
+			// additionally, add parent's nodeType on leave too
+			addParentNodeTypeOnLeave = true;
+		}
+
+		@Override
+		public Validator childNodeAdded(String childName, NodeState after) throws CommitFailedException {
+			addParentPathOnLeave = true;
+			addParentNodeNameOnLeave = true;
+			addParentNodeTypeOnLeave = true;
+			return newChildCollector(after, childName);
+		}
+
+		@Override
+		public Validator childNodeChanged(String childName, NodeState before, NodeState after) throws CommitFailedException {
+			if (level == support.getMaxPathDepth()) {
+				// then we'll cut off further paths below.
+				// to compensate, add the current path at this level
+				support.getParentPaths().add(path);
+				
+				// however, continue normally to handle names/types/properties below
+			}
+
+			return newChildCollector(after, childName);
+		}
+
+		@Override
+		public Validator childNodeDeleted(String childName, NodeState before) throws CommitFailedException {
+			addParentPathOnLeave = true;
+			addParentNodeNameOnLeave = true;
+			addParentNodeTypeOnLeave = true;
+			return newChildCollector(before, childName);
+		}
+    	
+    }
+
+	private int maxItems = DEFAULT_MAX_ITEMS;
+
+	private int maxPathDepth = DEFAULT_MAX_PATH_DEPTH;
+    
+    @Activate
+    protected void activate(ComponentContext context, Map<String, ?> config) {
+        maxItems = toInteger(config.get(PROP_MAX_ITEMS), DEFAULT_MAX_ITEMS);
+        maxPathDepth  = toInteger(config.get(PROP_MAX_PATH_DEPTH), DEFAULT_MAX_PATH_DEPTH);
+    }
+    
+    /** FOR TESTING-ONLY **/
+    protected void setMaxPathDepth(int maxPathDepth) {
+    	this.maxPathDepth = maxPathDepth;
+    }
+
+    @Override
+    protected Validator getRootValidator(NodeState before, NodeState after, CommitInfo info) {
+        if (info == null || info == CommitInfo.EMPTY) {
+        	// then we cannot do change-collecting, as we can't store
+        	// it in the info
+        	return null;
+        }
+        
+        return ChangeCollector.newRootCollector(info, maxItems, maxPathDepth);
+    }
+
+}

Property changes on: oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/observation/ChangeCollectorProvider.java
___________________________________________________________________
Added: svn:eol-style
## -0,0 +1 ##
+native
\ No newline at end of property
Index: oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/observation/ChangeSet.java
===================================================================
--- oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/observation/ChangeSet.java	(revision 0)
+++ oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/observation/ChangeSet.java	(working copy)
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.jackrabbit.oak.plugins.observation;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import com.google.common.collect.ImmutableSet;
+
+/**
+ * A ChangeSet is a collection of items that have been changed as part of a commit.
+ * <p>
+ * Those items are parent paths, parent node names, parent node types and
+ * (child) properties.
+ * 'Changed' refers to any of add, remove, change (where applicable).
+ * <p>
+ * A ChangeSet is piggybacked on a CommitInfo in the CommitContext and can
+ * be used by (downstream) Observers for their convenience.
+ * <p>
+ * To limit memory usage, the ChangeSet has a limit on the number of items, each,
+ * that it collects. If one of those items reach the limit this is called
+ * an 'overflow' and the corresponding item type is marked as having 'overflown'.
+ * Downstream Observers should thus check if a particular item has overflown
+ * or not.
+ * <p>
+ * Also, the ChangeSet carries a 'maxPathDepth' which is the depth of the path
+ * up until which paths have been collected. Thus any path that is longer than
+ * this 'maxPathDepth' will be cut off and only reported up to that max depth.
+ * Downstream Observers should thus inspect the 'maxPathDepth' and compare
+ * actual path depths with it in order to find out if any child paths have
+ * been cut off.
+ * <p>
+ * Naming: note that path, node name and node types all refer to the *parent* 
+ * of a change. While properties naturally are leafs.
+ */
+public class ChangeSet {
+	
+	private final int maxItems;
+	private final int maxPathDepth;
+	private final Set<String> parentPaths;
+	private final Set<String> parentNodeNames;
+	private final Set<String> parentNodeTypes;
+	private final Set<String> propertyNames;
+	
+	private boolean parentPathOverflow;
+	private boolean parentNodeNameOverflow;
+	private boolean parentNodeTypeOverflow;
+	private boolean propertyNameOverflow;
+	
+	public ChangeSet(int maxItems, int maxPathDepth) {
+		this(maxItems, maxPathDepth, 
+				false, new HashSet<String>(), 
+				false, new HashSet<String>(),
+				false, new HashSet<String>(),
+				false, new HashSet<String>());
+	}
+	
+	private ChangeSet(int maxItems, int maxPathDepth,
+			boolean parentPathOverflow,
+			Set<String> parentPaths,
+			boolean parentNodeNameOverflow,
+			Set<String> parentNodeNames,
+			boolean parentNodeTypeOverflow,
+			Set<String> parentNodeTypes,
+			boolean propertyNameOverflow,
+			Set<String> propertyNames) {
+		this.maxItems = maxItems;
+		this.maxPathDepth = maxPathDepth;
+		this.parentPathOverflow = parentPathOverflow;
+		this.parentPaths = parentPaths;
+		this.parentNodeNameOverflow = parentNodeNameOverflow;
+		this.parentNodeNames = parentNodeNames;
+		this.parentNodeTypeOverflow = parentNodeTypeOverflow;
+		this.parentNodeTypes = parentNodeTypes;
+		this.propertyNameOverflow = propertyNameOverflow;
+		this.propertyNames = propertyNames;
+	}
+	
+	@Override
+	public String toString() {
+		return "ChangeSet{paths[maxDepth:"+maxPathDepth+"]="+parentPaths+", propertyNames="+propertyNames+", nodeNames="+parentNodeNames+", nodeTypes="+parentNodeTypes+"}";
+	}
+	
+	public boolean getParentPathOverflown() {
+		return parentPathOverflow;
+	}
+	
+	public Set<String> getParentPaths() {
+		if (parentPathOverflow || parentPaths.size() > maxItems) {
+			// if already overflown, reset the buffers anyway
+			parentPathOverflow = true;
+			parentPaths.clear();
+		}
+		return parentPaths;
+	}
+
+	public boolean getParentNodeNameOverflown() {
+		return parentNodeNameOverflow;
+	}
+	
+	public Set<String> getParentNodeNames() {
+		if (parentNodeNameOverflow || parentNodeNames.size() > maxItems) {
+			// if already overflown, reset the buffers anyway
+			parentNodeNameOverflow = true;
+			parentNodeNames.clear();
+		}
+		return parentNodeNames;
+	}
+	
+	public boolean getParentNodeTypeOverflown() {
+		return parentNodeTypeOverflow;
+	}
+
+	public Set<String> getParentNodeTypes() {
+		if (parentNodeTypeOverflow || parentNodeTypes.size() > maxItems) {
+			// if already overflown, reset the buffers anyway
+			parentNodeTypeOverflow = true;
+			parentNodeTypes.clear();
+		}
+		return parentNodeTypes;
+	}
+	
+	public boolean getPropertyNameOverflown() {
+		return propertyNameOverflow;
+	}
+	
+	public Set<String> getPropertyNames() {
+		if (propertyNameOverflow || propertyNames.size() > maxItems) {
+			// if already overflown, reset the buffers anyway
+			propertyNameOverflow = true;
+			propertyNames.clear();
+		}
+		return propertyNames;
+	}
+
+	public int getMaxPrefilterPathDepth() {
+		return maxPathDepth;
+	}
+	
+	public ChangeSet asImmutable() {
+		return new ChangeSet(maxItems, maxPathDepth,
+				parentPathOverflow,
+				parentPathOverflow ? null : ImmutableSet.copyOf(parentPaths),
+				parentNodeNameOverflow,
+				parentNodeNameOverflow ? null : ImmutableSet.copyOf(parentNodeNames),
+				parentNodeTypeOverflow,
+				parentNodeTypeOverflow ? null : ImmutableSet.copyOf(parentNodeTypes),
+				propertyNameOverflow,
+				propertyNameOverflow ? null : ImmutableSet.copyOf(propertyNames));
+	}
+}
\ No newline at end of file

Property changes on: oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/observation/ChangeSet.java
___________________________________________________________________
Added: svn:eol-style
## -0,0 +1 ##
+native
\ No newline at end of property
Index: oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/observation/ChangeCollectorProviderTest.java
===================================================================
--- oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/observation/ChangeCollectorProviderTest.java	(revision 0)
+++ oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/observation/ChangeCollectorProviderTest.java	(working copy)
@@ -0,0 +1,482 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.jackrabbit.oak.plugins.observation;
+
+import static org.apache.jackrabbit.oak.commons.PathUtils.concat;
+import static org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState.EMPTY_NODE;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+import javax.jcr.NoSuchWorkspaceException;
+import javax.security.auth.Subject;
+import javax.security.auth.login.LoginException;
+
+import org.apache.jackrabbit.oak.Oak;
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.api.ContentRepository;
+import org.apache.jackrabbit.oak.api.ContentSession;
+import org.apache.jackrabbit.oak.api.Root;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.plugins.nodetype.write.InitialContent;
+import org.apache.jackrabbit.oak.security.SecurityProviderImpl;
+import org.apache.jackrabbit.oak.spi.commit.CommitContext;
+import org.apache.jackrabbit.oak.spi.commit.CommitInfo;
+import org.apache.jackrabbit.oak.spi.commit.Observer;
+import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
+import org.apache.jackrabbit.oak.spi.security.SecurityProvider;
+import org.apache.jackrabbit.oak.spi.security.authentication.SystemSubject;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeCollectorProviderTest {
+
+	ChangeCollectorProvider collectorProvider;
+	private ContentRepository contentRepository;
+	private ContentSession session;
+	private Recorder recorder;
+	private SecurityProviderImpl securityProvider;
+	
+	class ContentChange {
+		final NodeState root;
+		final CommitInfo info;
+		ContentChange(NodeState root, CommitInfo info) {
+			this.root = root;
+			this.info = info;
+		}
+	}
+	
+	class Recorder implements Observer {
+		List<ContentChange> changes = new LinkedList<ContentChange>();
+		
+		@Override
+		public void contentChanged(NodeState root, CommitInfo info) {
+			changes.add(new ContentChange(root, info));
+		}
+		
+	}
+	
+    protected SecurityProvider getSecurityProvider() {
+        if (securityProvider == null) {
+            securityProvider = new SecurityProviderImpl(ConfigurationParameters.EMPTY);
+        }
+        return securityProvider;
+    }
+
+    /**
+     * Checks that the actual string set provided matches the expected one.
+     * A match is when all elements occur, irrespective of the order.
+     */
+    private void assertMatches(String msg, Set<String> actuals, String... expected) {
+		if ((actuals == null || actuals.size() == 0) && expected.length != 0) {
+			fail("assertion failed for '"+msg+"': expected length " + expected.length + " != actual 0."
+					+ " Expected: '" + Arrays.toString(expected) + "', got: '" + actuals + "'");
+		} else if (expected.length == 0 && actuals != null && actuals.size() != 0) {
+			fail("assertion failed for '"+msg+"': expected length == 0, actual " + actuals.size() + "."
+					+ " Expected: '" + Arrays.toString(expected) + "', got: '" + actuals + "'");
+		} else if (expected.length != actuals.size()) {
+			fail("assertion failed for '"+msg+"': expected length (" + expected.length + ") != actual (" + actuals.size()+")."
+					+ " Expected: '" + Arrays.toString(expected) + "', got: '" + actuals + "'");
+		}
+		for (String anExpected : expected) {
+			if (!actuals.contains(anExpected)) {
+				fail("assertion failed for '"+msg+"': expected '"+anExpected + "' not found. Got: '" + actuals + "'");
+			}
+		}
+	}
+
+    /**
+     * Assumes that the recorder got 1 call, and extracts the ChangeSet from that call
+     */
+	private ChangeSet getSingleChangeSet() {
+		assertEquals(recorder.changes.size(), 1);
+		CommitContext commitContext = (CommitContext) recorder.changes.get(0).info.getInfo().get(CommitContext.NAME);
+		assertNotNull(commitContext);
+		ChangeSet changeSet = (ChangeSet) commitContext.get(ChangeCollectorProvider.COMMIT_CONTEXT_OBSERVATION_CHANGESET);
+		assertNotNull(changeSet);
+		return changeSet;
+	}
+	
+	@Before
+    public void setup() throws PrivilegedActionException, CommitFailedException {
+		collectorProvider = new ChangeCollectorProvider();
+		recorder = new Recorder();
+        Oak oak = new Oak()
+                .with(new InitialContent())
+                .with(collectorProvider)
+                .with(recorder)
+                .with(getSecurityProvider());
+        contentRepository = oak.createContentRepository();
+
+        session = Subject.doAs(SystemSubject.INSTANCE, new PrivilegedExceptionAction<ContentSession>() {
+            @Override
+            public ContentSession run() throws LoginException, NoSuchWorkspaceException {
+                return contentRepository.login(null, null);
+            }
+        });
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/").addChild("test");
+		rootTree.setProperty("jcr:primaryType", "test:parentType", Type.NAME);
+		Tree child1 = rootTree.addChild("child1");
+		child1.setProperty("child1Prop", 1);
+		child1.setProperty("jcr:primaryType", "test:childType", Type.NAME);
+		Tree grandChild1 = child1.addChild("grandChild1");
+		grandChild1.setProperty("grandChild1Prop", 1);
+		grandChild1.setProperty("jcr:primaryType", "test:grandChildType", Type.NAME);
+		Tree greatGrandChild1 = grandChild1.addChild("greatGrandChild1");
+		greatGrandChild1.setProperty("greatGrandChild1Prop", 1);
+		greatGrandChild1.setProperty("jcr:primaryType", "test:greatGrandChildType", Type.NAME);
+		Tree child2 = rootTree.addChild("child2");
+		child2.setProperty("child2Prop", 1);
+		child2.setProperty("jcr:primaryType", "test:childType", Type.NAME);
+		Tree grandChild2 = child2.addChild("grandChild2");
+		grandChild2.setProperty("grandChild2Prop", 1);
+		grandChild2.setProperty("jcr:primaryType", "test:grandChildType", Type.NAME);
+		root.commit();
+
+		// clear the recorder so that we start off empty
+		recorder.changes.clear();
+	}
+	
+	@Test
+	public void testNull() {
+		NodeBuilder builder = EMPTY_NODE.builder();
+		builder.setChildNode("test");
+		builder.setChildNode("a1").setChildNode("b1").setProperty("p1", 1);
+		NodeState before = builder.getNodeState();
+
+		builder = before.builder();
+        builder.setChildNode("a2").setChildNode("b12").setProperty("p12", "12");
+        NodeState after = builder.getNodeState();
+        
+		assertNull(collectorProvider.getRootValidator(before, after, null));
+		assertNotNull(collectorProvider.getRootValidator(before, after, CommitInfo.EMPTY));
+	}
+	
+	@Test
+	public void testRemoveChild() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		assertTrue(rootTree.getChild("child1").remove());
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test", "/test/child1", "/test/child1/grandChild1", "/test/child1/grandChild1/greatGrandChild1");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "test", "child1", "grandChild1", "greatGrandChild1");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:parentType", "test:childType", "test:grandChildType", "test:greatGrandChildType");
+		assertMatches("propertyNames", changeSet.getPropertyNames(), "jcr:primaryType", "child1Prop", "grandChild1Prop", "greatGrandChild1Prop");
+	}
+	
+	@Test
+	public void testRemoveGreatGrandChild() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		assertTrue(rootTree.getChild("child1").getChild("grandChild1").getChild("greatGrandChild1").remove());
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test/child1/grandChild1/greatGrandChild1", "/test/child1/grandChild1");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "grandChild1", "greatGrandChild1");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:greatGrandChildType", "test:grandChildType");
+		assertMatches("propertyNames", changeSet.getPropertyNames(), "jcr:primaryType", "greatGrandChild1Prop");
+	}
+	
+	@Test
+	public void testChangeGreatGrandChild() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		rootTree.getChild("child1").getChild("grandChild1").getChild("greatGrandChild1").setProperty("greatGrandChild1Prop", 2);
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test/child1/grandChild1/greatGrandChild1");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "greatGrandChild1");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:greatGrandChildType");
+		assertMatches("propertyNames", changeSet.getPropertyNames(), "greatGrandChild1Prop");
+	}
+
+	@Test
+	public void testChangeGreatAndGrandChild() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		rootTree.getChild("child1").getChild("grandChild1").setProperty("grandChild1Prop", 2);
+		rootTree.getChild("child1").getChild("grandChild1").getChild("greatGrandChild1").setProperty("greatGrandChild1Prop", 2);
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test/child1/grandChild1", "/test/child1/grandChild1/greatGrandChild1");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "grandChild1", "greatGrandChild1");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:grandChildType", "test:greatGrandChildType");
+		assertMatches("propertyNames", changeSet.getPropertyNames(), "grandChild1Prop", "greatGrandChild1Prop");
+	}
+
+	@Test
+	public void testAddEmptyChild() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		rootTree.addChild("child");
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "test");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:parentType");
+		assertMatches("propertyNames", changeSet.getPropertyNames());
+	}
+	
+	@Test
+	public void testAddEmptyGrandChild() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		Tree child = rootTree.addChild("child");
+		child.addChild("grandChild");
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test", "/test/child");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "test", "child");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:parentType");
+		assertMatches("propertyNames", changeSet.getPropertyNames());
+	}
+	
+	@Test
+	public void testAddNonEmptyGrandChild() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		Tree child = rootTree.addChild("child");
+		child.setProperty("childProperty", 1);
+		Tree grandChild = child.addChild("grandChild");
+		grandChild.setProperty("grandChildProperty", 2);
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test", "/test/child", "/test/child/grandChild");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "test", "child", "grandChild");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:parentType");
+		assertMatches("propertyNames", changeSet.getPropertyNames(), "childProperty", "grandChildProperty");
+	}
+	
+	@Test
+	public void testAddSomeChildren() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		for(int i=0; i<10; i++) {
+			Tree child = rootTree.addChild("x"+i);
+			child.setProperty("jcr:primaryType", "test:type"+i, Type.NAME);
+			child.setProperty("foo"+i, "bar");
+		}
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test", "/test/x0", "/test/x1", "/test/x2", "/test/x3", "/test/x4", "/test/x5", "/test/x6", "/test/x7", "/test/x8", "/test/x9");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "test", "x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7", "x8", "x9");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:parentType", "test:type0", "test:type1", "test:type2", "test:type3", "test:type4", "test:type5", "test:type6", "test:type7", "test:type8", "test:type9");
+		assertMatches("propertyNames", changeSet.getPropertyNames(), "jcr:primaryType", "foo0", "foo1", "foo2", "foo3", "foo4", "foo5", "foo6", "foo7", "foo8", "foo9");
+	}
+	
+	@Test
+	public void testAddEmptyRemoveChildren() throws CommitFailedException {
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		Tree child = rootTree.addChild("child");
+		child.addChild("grandChild");
+		assertTrue(rootTree.getChild("child2").remove());
+
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths", changeSet.getParentPaths(), "/test", "/test/child", "/test/child2", "/test/child2/grandChild2");
+		assertMatches("parentNodeNames", changeSet.getParentNodeNames(), "test", "child", "child2", "grandChild2");
+		assertMatches("parentNodeTypes", changeSet.getParentNodeTypes(), "test:parentType", "test:childType", "test:grandChildType");
+		assertMatches("propertyNames", changeSet.getPropertyNames(), "jcr:primaryType", "child2Prop", "grandChild2Prop");
+	}
+	
+	@Test
+	public void testAddMaxPathDepthAll() throws CommitFailedException, PrivilegedActionException {
+		for(int i=0; i<16; i++) {
+			setup();
+			doAddMaxPathDepth(i);
+		}
+	}
+	
+	private void doAddMaxPathDepth(int maxPathDepth) throws CommitFailedException {
+		collectorProvider.setMaxPathDepth(maxPathDepth);
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		Tree next = rootTree;
+		for(int i=0; i<16; i++) {
+			next = next.addChild("n"+i);
+			if (i%3!=0) {
+				next.setProperty("nextProp"+i, i);
+				next.setProperty("jcr:primaryType", i%2==0 ? "test:even" : "test:odd", Type.NAME);
+			}
+		}
+		root.commit();
+		ChangeSet changeSet = getSingleChangeSet();
+		List<String> expectedParentPaths = new LinkedList<String>();
+		if (maxPathDepth == 0) {
+			expectedParentPaths.add("/");
+		} else {
+			expectedParentPaths.add("/test");
+		}
+		for(int i=0; i<maxPathDepth-1; i++) {
+			StringBuffer path = new StringBuffer("/test");
+			for(int j=0; j<i; j++) {
+				path.append("/n"+j);
+			}
+			expectedParentPaths.add(path.toString());
+		}
+		assertMatches("parentPaths-"+maxPathDepth, changeSet.getParentPaths(), expectedParentPaths.toArray(new String[0]));
+		assertMatches("parentNodeNames-"+maxPathDepth, changeSet.getParentNodeNames(), "test", "n0", "n1", "n2", "n3", "n4", "n5", "n6", "n7", "n8", "n9", "n10", "n11", "n12", "n13", "n14"/*, "n15"*/);
+		assertMatches("parentNodeTypes-"+maxPathDepth, changeSet.getParentNodeTypes(), "test:parentType", "test:even", "test:odd");
+		assertMatches("propertyNames-"+maxPathDepth, changeSet.getPropertyNames(), "jcr:primaryType", /*"nextProp0", */"nextProp1", "nextProp2", /*"nextProp3",*/ "nextProp4", "nextProp5"/*, "nextProp6"*/
+				, "nextProp7", "nextProp8", /*"nextProp9", */"nextProp10", "nextProp11", /*"nextProp12",*/ "nextProp13", "nextProp14"/*, "nextProp15"*/);
+	}
+
+	@Test
+	public void testRemoveMaxPathDepthAll() throws CommitFailedException, PrivilegedActionException {
+		for(int i=0; i<16; i++) {
+			setup();
+			doRemoveMaxPathDepth(i);
+		}
+	}
+	
+	private void doRemoveMaxPathDepth(int maxPathDepth) throws CommitFailedException {
+		collectorProvider.setMaxPathDepth(maxPathDepth);
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		Tree next = rootTree;
+		for(int i=0; i<16; i++) {
+			next = next.addChild("n"+i);
+			if (i%3!=0) {
+				next.setProperty("nextProp"+i, i);
+				next.setProperty("jcr:primaryType", i%2==0 ? "test:even" : "test:odd", Type.NAME);
+			}
+		}
+		root.commit();
+		
+		// now do the delete
+		recorder.changes.clear();
+		root = session.getLatestRoot();
+		rootTree = root.getTree("/test");
+		next = rootTree;
+		for(int i=0; i<15; i++) {
+			next = next.getChild("n"+i);
+			if (i == 14) {
+				next.remove();
+			}
+		}
+		root.commit();
+		
+		ChangeSet changeSet = getSingleChangeSet();
+		Set<String> expectedParentPaths = new HashSet<String>();
+		String path = "/";
+		if (maxPathDepth == 1) {
+			path = "/test"; 
+		} else if (maxPathDepth > 1) {
+			path = "/test";
+			for(int i=0; i<maxPathDepth - 1; i++) {
+				path = concat(path, "n" + i);
+			}
+		}
+		expectedParentPaths.add(path);
+		assertMatches("parentPaths-"+maxPathDepth, changeSet.getParentPaths(), expectedParentPaths.toArray(new String[0]));
+		assertMatches("parentNodeNames-"+maxPathDepth, changeSet.getParentNodeNames(), "n13", "n14");
+		assertMatches("parentNodeTypes-"+maxPathDepth, changeSet.getParentNodeTypes(), "test:even", "test:odd");
+		assertMatches("propertyNames-"+maxPathDepth, changeSet.getPropertyNames(), "jcr:primaryType", "nextProp14");
+	}
+
+	@Test
+	public void testChangeMaxPathDepthAll() throws CommitFailedException, PrivilegedActionException {
+		for(int maxPathDepth=0; maxPathDepth<16; maxPathDepth++) {
+			for(int changeAt=0; changeAt<16; changeAt++) {
+				setup();
+				doChangeMaxPathDepth(changeAt, maxPathDepth);
+			}
+		}
+	}
+	
+	private void doChangeMaxPathDepth(int changeAt, int maxPathDepth) throws CommitFailedException {
+		collectorProvider.setMaxPathDepth(maxPathDepth);
+		Root root = session.getLatestRoot();
+		Tree rootTree = root.getTree("/test");
+		Tree next = rootTree;
+		for(int i=0; i<16; i++) {
+			next = next.addChild("n"+i);
+			if (i%3!=0) {
+				next.setProperty("nextProp"+i, i);
+				next.setProperty("jcr:primaryType", i%2==0 ? "test:even" : "test:odd", Type.NAME);
+			}
+		}
+		root.commit();
+		recorder.changes.clear();
+		
+		// now do the change
+		root = session.getLatestRoot();
+		rootTree = root.getTree("/test");
+		next = rootTree;
+		List<String> expectedParentPaths = new LinkedList<String>();
+		List<String> expectedParentNodeNames = new LinkedList<String>();
+		List<String> expectedParentNodeTypes = new LinkedList<String>();
+		List<String> expectedPropertyNames = new LinkedList<String>();
+		expectedPropertyNames.add("jcr:primaryType");
+		String parent = "/";
+		if (maxPathDepth > 0) {
+			parent = "/test";
+		}
+		for(int i=0; i<=changeAt; i++) {
+			String childName = "n"+i;
+			next = next.getChild(childName);
+			if (i < maxPathDepth - 1) {
+				parent = concat(parent, childName);
+			}
+			if (i == changeAt) {
+				expectedParentNodeNames.add(next.getName());
+				String propertyName = "nextProp"+i;
+				next.setProperty(propertyName, i+1);
+				expectedPropertyNames.add(propertyName);
+				final String nodeTypeName = i%2==0 ? "test:evenChanged" : "test:oddChanged";
+				expectedParentNodeTypes.add(nodeTypeName);
+				next.setProperty("jcr:primaryType", nodeTypeName, Type.NAME);
+			}
+		}
+		expectedParentPaths.add(parent);
+		root.commit();
+		
+		ChangeSet changeSet = getSingleChangeSet();
+		assertMatches("parentPaths-"+changeAt+"-"+maxPathDepth, changeSet.getParentPaths(), expectedParentPaths.toArray(new String[0]));
+		assertMatches("parentNodeNames-"+changeAt+"-"+maxPathDepth, changeSet.getParentNodeNames(), expectedParentNodeNames.toArray(new String[0]));
+		assertMatches("parentNodeTypes-"+changeAt+"-"+maxPathDepth, changeSet.getParentNodeTypes(), expectedParentNodeTypes.toArray(new String[0]));
+		assertMatches("propertyNames-"+changeAt+"-"+maxPathDepth, changeSet.getPropertyNames(), expectedPropertyNames.toArray(new String[0]));
+	}
+	
+	
+}
\ No newline at end of file

Property changes on: oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/observation/ChangeCollectorProviderTest.java
___________________________________________________________________
Added: svn:eol-style
## -0,0 +1 ##
+native
\ No newline at end of property
