/*
* 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.kernel;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.util.Collections.emptyList;
import static org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState.EMPTY_NODE;
import static org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState.MISSING_NODE;
import static org.apache.jackrabbit.oak.plugins.memory.PropertyStates.createProperty;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import javax.annotation.Nonnull;
import javax.jcr.PropertyType;
import com.google.common.base.Function;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import org.apache.jackrabbit.mk.api.MicroKernel;
import org.apache.jackrabbit.mk.api.MicroKernelException;
import org.apache.jackrabbit.mk.json.JsopReader;
import org.apache.jackrabbit.mk.json.JsopTokenizer;
import org.apache.jackrabbit.oak.api.PropertyState;
import org.apache.jackrabbit.oak.api.Type;
import org.apache.jackrabbit.oak.commons.PathUtils;
import org.apache.jackrabbit.oak.plugins.memory.BinaryPropertyState;
import org.apache.jackrabbit.oak.plugins.memory.BooleanPropertyState;
import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState;
import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeBuilder;
import org.apache.jackrabbit.oak.plugins.memory.PropertyStates;
import org.apache.jackrabbit.oak.plugins.memory.StringPropertyState;
import org.apache.jackrabbit.oak.plugins.value.Conversions;
import org.apache.jackrabbit.oak.spi.state.AbstractChildNodeEntry;
import org.apache.jackrabbit.oak.spi.state.AbstractNodeState;
import org.apache.jackrabbit.oak.spi.state.ChildNodeEntry;
import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
import org.apache.jackrabbit.oak.spi.state.NodeState;
import org.apache.jackrabbit.oak.spi.state.NodeStateDiff;
/**
* Basic {@link NodeState} implementation based on the {@link MicroKernel}
* interface. This class makes an attempt to load data lazily.
*/
public final class KernelNodeState extends AbstractNodeState {
/**
* Maximum number of child nodes kept in memory.
*/
static final int MAX_CHILD_NODE_NAMES = 100;
/**
* Number of child nodes beyond which {@link MicroKernel#diff(String, String, String, int)}
* is used for diffing.
*/
public static final int LOCAL_DIFF_THRESHOLD = 10;
/**
* Dummy cache instance for static {@link #NULL} kernel node state.
*/
private static final LoadingCache<String, KernelNodeState> DUMMY_CACHE =
CacheBuilder.newBuilder().build(new CacheLoader<String, KernelNodeState>() {
@Override
public KernelNodeState load(String key) throws Exception {
throw new UnsupportedOperationException();
}
});
/**
* This {@code NULL} kernel node state is used as a value in the
* {@link #cache} to indicate that there is no node state at the given
* path and revision. This object is only used internally and never leaves
* this {@link KernelNodeState}.
*/
private static final KernelNodeState NULL = new KernelNodeState();
private final KernelNodeStore store;
private final MicroKernel kernel;
private final String path;
private String revision;
private Map<String, PropertyState> properties;
private long childNodeCount = -1;
private long childNodeCountMin;
private String hash;
private String id;
private Set<String> childNames;
/**
* {@code true} is this is a node state from a branch. {@code false}
* otherwise.
* <p>
* FIXME: this is a workaround to avoid creating branches from a branch
* until this is supported by the MicroKernel. See {@link KernelNodeState#builder()}.
*/
private boolean isBranch;
private final LoadingCache<String, KernelNodeState> cache;
/**
* Create a new instance of this class representing the node at the
* given {@code path} and {@code revision}. It is an error if the
* underlying Microkernel does not contain such a node.
*
* @param store the underlying KernelNodeStore
* @param path the path of this KernelNodeState
* @param revision the revision of the node to read from the kernel.
* @param cache the KernelNodeState cache
*/
public KernelNodeState(
KernelNodeStore store, String path, String revision,
LoadingCache<String, KernelNodeState> cache) {
this.store = store;
this.kernel = store.getKernel();
this.path = checkNotNull(path);
this.revision = checkNotNull(revision);
this.cache = checkNotNull(cache);
}
private KernelNodeState() {
this.store = null;
this.kernel = null;
this.path = "null";
this.revision = "null";
this.cache = DUMMY_CACHE;
}
private void init() {
boolean initialized = false;
synchronized (this) {
if (properties == null) {
String json = kernel.getNodes(
path, revision, 0, 0, MAX_CHILD_NODE_NAMES,
"{\"properties\":[\"*\",\":hash\",\":id\"]}");
JsopReader reader = new JsopTokenizer(json);
reader.read('{');
properties = new LinkedHashMap<String, PropertyState>();
childNames = new LinkedHashSet<String>();
do {
String name = StringCache.get(reader.readString());
reader.read(':');
if (":childNodeCount".equals(name)) {
childNodeCount =
Long.valueOf(reader.read(JsopReader.NUMBER));
} else if (":hash".equals(name)) {
hash = new String(reader.read(JsopReader.STRING));
if (hash.equals(id)) {
// save some memory
hash = id;
}
} else if (":id".equals(name)) {
id = new String(reader.read(JsopReader.STRING));
if (id.equals(hash)) {
// save some memory
id = hash;
}
} else if (reader.matches('{')) {
reader.read('}');
childNames.add(name);
} else if (reader.matches('[')) {
properties.put(name, readArrayProperty(name, reader));
} else {
properties.put(name, readProperty(name, reader));
}
} while (reader.matches(','));
reader.read('}');
reader.read(JsopReader.END);
// optimize for empty childNodes
if (childNames.isEmpty()) {
childNames = Collections.emptySet();
}
initialized = true;
}
}
if (initialized) {
// refresh cache to force re-calculation of weight (OAK-643)
cache.refresh(revision + path);
}
if (initialized && !PathUtils.denotesRoot(path)) {
// OAK-591: check if we can re-use a previous revision
// by looking up the node state by hash or id (if available)
// introducing this secondary lookup basically means we point
// back to a subtree in an older revision, in case it didn't change
String hashOrId = null;
if (hash != null) {
// hash takes precedence
hashOrId = hash;
} else if (id != null) {
hashOrId = id;
}
if (hashOrId != null) {
KernelNodeState cached = cache.getIfPresent(hashOrId);
if (cached != null && cached.path.equals(this.path)) {
synchronized (this) {
this.revision = cached.revision;
this.childNames = cached.childNames;
this.properties = cached.properties;
}
} else {
// store under secondary key
cache.put(hashOrId, this);
}
}
}
}
@Override
public boolean exists() {
return true;
}
@Override
public long getPropertyCount() {
init();
return properties.size();
}
@Override
public boolean hasProperty(String name) {
init();
return properties.containsKey(name);
}
@Override
public PropertyState getProperty(String name) {
init();
return properties.get(name);
}
@Override
public Iterable<? extends PropertyState> getProperties() {
init();
return properties.values();
}
@Override
public long getChildNodeCount(long max) {
init();
if (childNodeCount == Long.MAX_VALUE) {
if (childNodeCountMin > max) {
// getChildNodeCount(max) was already called,
// and we know the value is higher than max
return childNodeCountMin;
}
// count the entries
Iterator<? extends ChildNodeEntry> iterator = getChildNodeEntries().iterator();
long n = 0;
while (n <= max) {
if (!iterator.hasNext()) {
// we know the exact number now
childNodeCount = n;
return n;
}
iterator.next();
n++;
}
// remember we have at least this number of entries
childNodeCountMin = n;
if (n == max) {
// we didn't count all entries
return max;
}
}
return childNodeCount;
}
@Override
public boolean hasChildNode(String name) {
checkNotNull(name);
init();
return childNames.contains(name)
|| (getChildNodeCount(MAX_CHILD_NODE_NAMES) > MAX_CHILD_NODE_NAMES
&& getChildNode(name).exists());
}
@Override
public NodeState getChildNode(String name) {
// checkArgument(!checkNotNull(name).isEmpty()); // TODO: check in higher level
init();
String childPath = null;
if (childNames.contains(name)) {
childPath = getChildPath(name);
}
if (childPath == null && getChildNodeCount(MAX_CHILD_NODE_NAMES) > MAX_CHILD_NODE_NAMES) {
String path = getChildPath(name);
// OAK-506: Avoid the nodeExists() call when already cached
NodeState state = cache.getIfPresent(revision + path);
if (state != null) {
return state == NULL ? MISSING_NODE : state;
}
// not able to tell from cache if node exists
// need to ask MicroKernel
if (kernel.nodeExists(path, revision)) {
childPath = path;
} else {
cache.put(revision + path, NULL);
}
}
if (childPath == null) {
return MISSING_NODE;
}
try {
return cache.get(revision + childPath);
} catch (ExecutionException e) {
throw new MicroKernelException(e);
}
}
@Override
public Iterable<? extends ChildNodeEntry> getChildNodeEntries() {
init();
if (childNodeCount <= MAX_CHILD_NODE_NAMES && childNodeCount <= childNames.size()) {
return iterable(childNames);
}
List<Iterable<ChildNodeEntry>> iterables = Lists.newArrayList();
iterables.add(iterable(childNames));
iterables.add(getChildNodeEntries(childNames.size()));
return Iterables.concat(iterables);
}
private Iterable<ChildNodeEntry> getChildNodeEntries(final long offset) {
return new Iterable<ChildNodeEntry>() {
@Override
public Iterator<ChildNodeEntry> iterator() {
return new Iterator<ChildNodeEntry>() {
private long currentOffset = offset;
private Iterator<ChildNodeEntry> current;
{
fetchEntries();
}
private void fetchEntries() {
List<ChildNodeEntry> entries = Lists
.newArrayListWithCapacity(MAX_CHILD_NODE_NAMES);
String json = kernel.getNodes(path, revision, 0,
currentOffset, MAX_CHILD_NODE_NAMES, null);
JsopReader reader = new JsopTokenizer(json);
reader.read('{');
do {
String name = StringCache.get(reader.readString());
reader.read(':');
if (reader.matches('{')) {
reader.read('}');
entries.add(new KernelChildNodeEntry(name));
} else if (reader.matches('[')) {
while (reader.read() != ']') {
// skip
}
} else {
reader.read();
}
} while (reader.matches(','));
reader.read('}');
reader.read(JsopReader.END);
if (entries.isEmpty()) {
current = null;
} else {
currentOffset += entries.size();
current = entries.iterator();
}
}
@Override
public boolean hasNext() {
while (true) {
if (current == null) {
return false;
} else if (current.hasNext()) {
return true;
}
fetchEntries();
}
}
@Override
public ChildNodeEntry next() {
if (!hasNext()) {
throw new IllegalStateException(
"Reading past the end");
}
return current.next();
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
};
}
};
}
/**
* This implementation returns a {@link KernelNodeBuilder} unless this is not
* a root node or {@link #isBranch} is {@code true} in which case a
* {@link MemoryNodeBuilder} is returned.
* <p>
* TODO: this is a workaround to avoid creating branches from a branch
* until this is supported by the MicroKernel.
*/
@Override
public NodeBuilder builder() {
if (isBranch) {
return new MemoryNodeBuilder(this);
} else if ("/".equals(path)) {
return new KernelRootBuilder(this, store);
} else {
return new MemoryNodeBuilder(this);
}
}
/**
* Optimised comparison method that can avoid traversing all properties
* and child nodes if both this and the given base node state come from
* the same MicroKernel and either have the same content hash (when
* available) or are located at the same path in different revisions.
*
* @see <a href="https://issues.apache.org/jira/browse/OAK-175">OAK-175</a>
*/
@Override
public boolean compareAgainstBaseState(NodeState base, NodeStateDiff diff) {
if (this == base) {
return true; // no differences
} else if (base == EMPTY_NODE || !base.exists()) { // special case
return EmptyNodeState.compareAgainstEmptyState(this, diff);
} else if (base instanceof KernelNodeState) {
KernelNodeState kbase = (KernelNodeState) base;
if (kernel.equals(kbase.kernel)) {
if (revision.equals(kbase.revision) && path.equals(kbase.path)) {
return true; // no differences
} else {
init();
kbase.init();
if (hash != null && hash.equals(kbase.hash)) {
return true; // no differences
} else if (id != null && id.equals(kbase.id)) {
return true; // no differences
} else if (path.equals(kbase.path)
&& getChildNodeCount(LOCAL_DIFF_THRESHOLD) > LOCAL_DIFF_THRESHOLD) {
// use MK.diff() when there are 'many' child nodes
String jsonDiff = kernel.diff(kbase.getRevision(), revision, path, 0);
return processJsonDiff(jsonDiff, kbase, diff);
}
}
}
}
// fall back to the generic node state diff algorithm
return super.compareAgainstBaseState(base, diff);
}
//------------------------------------------------------------< Object >--
/**
* Optimised equality check that can avoid a full tree comparison if
* both instances come from the same MicroKernel and have either
* the same revision and path or the same content hash (when available).
* Otherwise we fall back to the default tree comparison algorithm.
*
* @see <a href="https://issues.apache.org/jira/browse/OAK-172">OAK-172</a>
*/
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
} else if (object instanceof KernelNodeState) {
KernelNodeState that = (KernelNodeState) object;
if (kernel.equals(that.kernel)) {
if (revision.equals(that.revision) && path.equals(that.path)) {
return true;
} else {
this.init();
that.init();
if (hash != null && that.hash != null) {
return hash.equals(that.hash);
} else if (id != null && id.equals(that.id)) {
// only return result of equals if ids are equal
// different ids doesn't mean the node states are
// definitively different.
return true;
} else if (path.equals(that.path) && !path.equals("/")) {
String r1 = revision, r2 = that.getRevision();
if (r1.compareTo(r2) > 0) {
// sort the revisions, to allow the MicroKernel to cache the result
String temp = r1;
r1 = r2;
r2 = temp;
}
String jsonDiff = kernel.diff(r1, r2, path, 0);
return !hasChanges(jsonDiff);
}
}
}
}
// fall back to the generic tree equality comparison algorithm
return super.equals(object);
}
//------------------------------------------------------------< internal >---
@Nonnull
String getRevision() {
return revision;
}
/**
* Mark this instance as from being on branch.
* <p>
* TODO this is a workaround to avoid creating branches from a branch
* until this is supported by the MicroKernel. See {@link KernelNodeState#builder()}.
* @return {@code this}
*/
KernelNodeState setBranch() {
isBranch = true;
return this;
}
/**
* @return {@code true} if this instance has been marked as being on a branch by a call
* to {@link #setBranch()}
*/
boolean isBranch() {
return isBranch;
}
/**
* @return the approximate memory usage of this node state.
*/
synchronized int getMemory() {
// base memory footprint is roughly 64 bytes
int memory = 64;
// path String
memory += 48 + path.length() * 2;
// revision String
memory += 48 + revision.length() * 2;
// optional hash String
if (hash != null) {
memory += 48 + hash.length() * 2;
}
// optional id String
if (id != null && !id.equals(hash)) {
memory += 48 + id.length() * 2;
}
// rough approximation for properties
if (properties != null) {
for (Map.Entry<String, PropertyState> entry : properties.entrySet()) {
// name
memory += 48 + entry.getKey().length() * 2;
PropertyState propState = entry.getValue();
if (propState.getType() != Type.BINARY
&& propState.getType() != Type.BINARIES) {
// assume binaries go into blob store
for (int i = 0; i < propState.count(); i++) {
// size() returns length of string
// overhead:
// - 8 bytes per reference in values list
// - 48 bytes per string
memory += 56 + propState.size(i) * 2;
}
}
}
}
// rough approximation for child nodes
if (childNames != null) {
memory += childNames.size() * 150;
}
return memory;
}
//------------------------------------------------------------< private >---
private static boolean hasChanges(String journal) {
return !journal.trim().isEmpty();
}
/**
* Process the given JSON diff, which is the diff of of the {@code base}
* node state to this node state.
*
* @param jsonDiff the JSON diff.
* @param base the base node state.
* @param diff where diffs are reported to.
* @return {@code true} to continue the comparison, {@code false} to stop
*/
private boolean processJsonDiff(String jsonDiff,
KernelNodeState base,
NodeStateDiff diff) {
if (!hasChanges(jsonDiff)) {
return true;
}
if (!AbstractNodeState.comparePropertiesAgainstBaseState(this, base, diff)) {
return false;
}
JsopTokenizer t = new JsopTokenizer(jsonDiff);
boolean continueComparison = true;
while (continueComparison) {
int r = t.read();
if (r == JsopReader.END) {
break;
}
switch (r) {
case '+': {
String path = t.readString();
t.read(':');
t.read('{');
while (t.read() != '}') {
// skip properties
}
String name = PathUtils.getName(path);
continueComparison = diff.childNodeAdded(name, getChildNode(name));
break;
}
case '-': {
String path = t.readString();
String name = PathUtils.getName(path);
continueComparison = diff.childNodeDeleted(name, base.getChildNode(name));
break;
}
case '^': {
String path = t.readString();
t.read(':');
if (t.matches('{')) {
t.read('}');
String name = PathUtils.getName(path);
continueComparison = diff.childNodeChanged(name,
base.getChildNode(name), getChildNode(name));
} else if (t.matches('[')) {
// ignore multi valued property
while (t.read() != ']') {
// skip values
}
} else {
// ignore single valued property
t.read();
}
break;
}
case '>': {
String from = t.readString();
t.read(':');
String to = t.readString();
String fromName = PathUtils.getName(from);
continueComparison = diff.childNodeDeleted(
fromName, base.getChildNode(fromName));
if (!continueComparison) {
break;
}
String toName = PathUtils.getName(to);
continueComparison = diff.childNodeAdded(
toName, getChildNode(toName));
break;
}
default:
throw new IllegalArgumentException("jsonDiff: illegal token '"
+ t.getToken() + "' at pos: " + t.getLastPos() + ' ' + jsonDiff);
}
}
return continueComparison;
}
private String getChildPath(String name) {
if ("/".equals(path)) {
return '/' + name;
} else {
return path + '/' + name;
}
}
private Iterable<ChildNodeEntry> iterable(
Iterable<String> names) {
return Iterables.transform(
names,
new Function<String, ChildNodeEntry>() {
@Override
public ChildNodeEntry apply(String input) {
return new KernelChildNodeEntry(input);
}
});
}
private class KernelChildNodeEntry extends AbstractChildNodeEntry {
private final String name;
/**
* Creates a child node entry with the given name.
*
* @param name child node name
*/
public KernelChildNodeEntry(String name) {
this.name = checkNotNull(name);
}
@Override
public String getName() {
return name;
}
@Override
public NodeState getNodeState() {
try {
return cache.get(revision + getChildPath(name));
} catch (ExecutionException e) {
throw new MicroKernelException(e);
}
}
}
/**
* Read a {@code PropertyState} from a {@link JsopReader}
* @param name The name of the property state
* @param reader The reader
* @return new property state
*/
private PropertyState readProperty(String name, JsopReader reader) {
if (reader.matches(JsopReader.NUMBER)) {
String number = reader.getToken();
return createProperty(name, number, PropertyType.LONG);
} else if (reader.matches(JsopReader.TRUE)) {
return BooleanPropertyState.booleanProperty(name, true);
} else if (reader.matches(JsopReader.FALSE)) {
return BooleanPropertyState.booleanProperty(name, false);
} else if (reader.matches(JsopReader.STRING)) {
String jsonString = reader.getToken();
if (jsonString.startsWith(TypeCodes.EMPTY_ARRAY)) {
int type = PropertyType.valueFromName(
jsonString.substring(TypeCodes.EMPTY_ARRAY.length()));
return PropertyStates.createProperty(
name, emptyList(), Type.fromTag(type, true));
}
int split = TypeCodes.split(jsonString);
if (split != -1) {
int type = TypeCodes.decodeType(split, jsonString);
String value = TypeCodes.decodeName(split, jsonString);
if (type == PropertyType.BINARY) {
return BinaryPropertyState.binaryProperty(
name, new KernelBlob(new String(value), kernel));
} else {
return createProperty(name, StringCache.get(value), type);
}
} else {
return StringPropertyState.stringProperty(
name, StringCache.get(jsonString));
}
} else {
throw new IllegalArgumentException("Unexpected token: " + reader.getToken());
}
}
/**
* Read a multi valued {@code PropertyState} from a {@link JsopReader}
* @param name The name of the property state
* @param reader The reader
* @return new property state
*/
private PropertyState readArrayProperty(String name, JsopReader reader) {
int type = PropertyType.STRING;
List<Object> values = Lists.newArrayList();
while (!reader.matches(']')) {
if (reader.matches(JsopReader.NUMBER)) {
String number = reader.getToken();
type = PropertyType.LONG;
values.add(Conversions.convert(number).toLong());
} else if (reader.matches(JsopReader.TRUE)) {
type = PropertyType.BOOLEAN;
values.add(true);
} else if (reader.matches(JsopReader.FALSE)) {
type = PropertyType.BOOLEAN;
values.add(false);
} else if (reader.matches(JsopReader.STRING)) {
String jsonString = reader.getToken();
int split = TypeCodes.split(jsonString);
if (split != -1) {
type = TypeCodes.decodeType(split, jsonString);
String value = TypeCodes.decodeName(split, jsonString);
if (type == PropertyType.BINARY) {
values.add(new KernelBlob(new String(value), kernel));
} else if (type == PropertyType.DOUBLE) {
values.add(Conversions.convert(value).toDouble());
} else if (type == PropertyType.DECIMAL) {
values.add(Conversions.convert(value).toDecimal());
} else if (type == PropertyType.DATE) {
values.add(Conversions.convert(value).toCalendar().getTimeInMillis());
} else {
values.add(StringCache.get(value));
}
} else {
type = PropertyType.STRING;
values.add(StringCache.get(jsonString));
}
} else {
throw new IllegalArgumentException("Unexpected token: " + reader.getToken());
}
reader.matches(',');
}
return createProperty(name, values, Type.fromTag(type, true));
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append(path).append('@').append(revision);
if (childNodeCount >= 0) {
builder.append(" children: ").append(childNodeCount);
}
if (hash != null) {
builder.append(" hash: ").append(hash);
}
if (id != null) {
builder.append(" id: ").append(id);
}
builder.append(" {");
int count = 0;
if (properties == null) {
builder.append(" /* props not initialized */");
} else {
for (PropertyState property : getProperties()) {
if (count++ > 0) {
builder.append(',');
}
builder.append(' ').append(property);
}
}
if (childNames == null) {
builder.append(" /* child node names not initialized */");
} else {
for (String s : childNames) {
if (count++ > 0) {
builder.append(',');
}
builder.append(' ').append(s);
}
}
builder.append(" }");
return builder.toString();
}
}