com.google.firebase.database.core.WriteTree Maven / Gradle / Ivy
/*
* Copyright 2017 Google Inc.
*
* Licensed 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 com.google.firebase.database.core;
import com.google.firebase.database.core.utilities.Predicate;
import com.google.firebase.database.core.view.CacheNode;
import com.google.firebase.database.snapshot.ChildKey;
import com.google.firebase.database.snapshot.EmptyNode;
import com.google.firebase.database.snapshot.Index;
import com.google.firebase.database.snapshot.NamedNode;
import com.google.firebase.database.snapshot.Node;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Defines a single user-initiated write operation. May be the result of a set(), transaction(), or
* update() call. In the case of a set() or transaction, snap wil be non-null. In the case of an
* update(), children will be non-null.
*/
public class WriteTree {
/** The default filter used when constructing the tree. Keep everything that's visible. */
private static final Predicate DEFAULT_FILTER =
new Predicate() {
@Override
public boolean evaluate(UserWriteRecord write) {
return write.isVisible();
}
};
/**
* A tree tracking the result of applying all visible writes. This does not include transactions
* with applyLocally=false or writes that are completely shadowed by other writes.
*/
private CompoundWrite visibleWrites;
/**
* A list of all pending writes, regardless of visibility and shadowed-ness. Used to calculate
* arbitrary sets of the changed data, such as hidden writes (from transactions) or changes with
* certain writes excluded (also used by transactions).
*/
private List allWrites;
private Long lastWriteId;
/**
* WriteTree tracks all pending user-initiated writes and has methods to calculate the result of
* merging them with underlying server data (to create "event cache" data). Pending writes are
* added with addOverwrite() and addMerge(), and removed with removeWrite().
*/
public WriteTree() {
this.visibleWrites = CompoundWrite.emptyWrite();
this.allWrites = new ArrayList<>();
this.lastWriteId = -1L;
}
/**
* Static method. Given an array of WriteRecords, a filter for which ones to include, and a path,
* construct a merge at that path.
*/
private static CompoundWrite layerTree(
List writes, Predicate filter, Path treeRoot) {
CompoundWrite compoundWrite = CompoundWrite.emptyWrite();
for (UserWriteRecord write : writes) {
// Theory, a later set will either:
// a) abort a relevant transaction, so no need to worry about excluding it from
// calculating
// that transaction
// b) not be relevant to a transaction (separate branch), so again will not affect
// the data
// for that transaction
if (filter.evaluate(write)) {
Path writePath = write.getPath();
if (write.isOverwrite()) {
if (treeRoot.contains(writePath)) {
Path relativePath = Path.getRelative(treeRoot, writePath);
compoundWrite = compoundWrite.addWrite(relativePath, write.getOverwrite());
} else if (writePath.contains(treeRoot)) {
compoundWrite =
compoundWrite.addWrite(
Path.getEmptyPath(),
write.getOverwrite().getChild(Path.getRelative(writePath, treeRoot)));
} else {
// There is no overlap between root path and write path, ignore write
}
} else {
if (treeRoot.contains(writePath)) {
Path relativePath = Path.getRelative(treeRoot, writePath);
compoundWrite = compoundWrite.addWrites(relativePath, write.getMerge());
} else if (writePath.contains(treeRoot)) {
Path relativePath = Path.getRelative(writePath, treeRoot);
if (relativePath.isEmpty()) {
compoundWrite = compoundWrite.addWrites(Path.getEmptyPath(), write.getMerge());
} else {
Node deepNode = write.getMerge().getCompleteNode(relativePath);
if (deepNode != null) {
compoundWrite = compoundWrite.addWrite(Path.getEmptyPath(), deepNode);
}
}
} else {
// There is no overlap between root path and write path, ignore write
}
}
}
}
return compoundWrite;
}
/**
* Create a new WriteTreeRef for the given path. For use with a new sync point at the given path.
*/
public WriteTreeRef childWrites(Path path) {
return new WriteTreeRef(path, this);
}
/** Record a new overwrite from user code. */
public void addOverwrite(Path path, Node snap, Long writeId, boolean visible) {
assert writeId > this.lastWriteId; // Stacking an older write on top of newer ones
this.allWrites.add(new UserWriteRecord(writeId, path, snap, visible));
if (visible) {
this.visibleWrites = this.visibleWrites.addWrite(path, snap);
}
this.lastWriteId = writeId;
}
/** Record a new merge from user code. */
public void addMerge(Path path, CompoundWrite changedChildren, Long writeId) {
assert writeId > this.lastWriteId; // Stacking an older write on top of newer ones
this.allWrites.add(new UserWriteRecord(writeId, path, changedChildren));
this.visibleWrites = this.visibleWrites.addWrites(path, changedChildren);
this.lastWriteId = writeId;
}
public UserWriteRecord getWrite(long writeId) {
for (UserWriteRecord record : this.allWrites) {
if (record.getWriteId() == writeId) {
return record;
}
}
return null;
}
public List purgeAllWrites() {
List purgedWrites = new ArrayList<>(this.allWrites);
// Reset everything
this.visibleWrites = CompoundWrite.emptyWrite();
this.allWrites = new ArrayList<>();
return purgedWrites;
}
/**
* Remove a write (either an overwrite or merge) that has been successfully acknowledge by the
* server. Recalculates the tree if necessary. We return whether the write may have been visible,
* meaning views need to reevaluate.
*
* @return true if the write may have been visible (meaning we'll need to reevaluate / raise
* events as a result).
*/
public boolean removeWrite(long writeId) {
// Note: disabling this check. It could be a transaction that preempted another
// transaction, and
// thus was applied out of order.
// var validClear = revert || this.allWrites_.length === 0 ||
// writeId <= this.allWrites_[0].writeId;
// fb.core.util.assert(validClear, "Either we don't have this write, or it's the first
// one in
// the queue");
// TODO: maybe use hashmap
UserWriteRecord writeToRemove = null;
int idx = 0;
for (UserWriteRecord record : this.allWrites) {
if (record.getWriteId() == writeId) {
writeToRemove = record;
break;
}
idx++;
}
assert writeToRemove != null : "removeWrite called with nonexistent writeId";
this.allWrites.remove(writeToRemove);
boolean removedWriteWasVisible = writeToRemove.isVisible();
boolean removedWriteOverlapsWithOtherWrites = false;
int i = this.allWrites.size() - 1;
while (removedWriteWasVisible && i >= 0) {
UserWriteRecord currentWrite = this.allWrites.get(i);
if (currentWrite.isVisible()) {
if (i >= idx && this.recordContainsPath(currentWrite, writeToRemove.getPath())) {
// The removed write was completely shadowed by a subsequent write.
removedWriteWasVisible = false;
} else if (writeToRemove.getPath().contains(currentWrite.getPath())) {
// Either we're covering some writes or they're covering part of us
// (depending on which
// came first).
removedWriteOverlapsWithOtherWrites = true;
}
}
i--;
}
if (!removedWriteWasVisible) {
return false;
} else if (removedWriteOverlapsWithOtherWrites) {
// There's some shadowing going on. Just rebuild the visible writes from scratch.
this.resetTree();
return true;
} else {
// There's no shadowing. We can safely just remove the write(s) from visibleWrites.
if (writeToRemove.isOverwrite()) {
this.visibleWrites = this.visibleWrites.removeWrite(writeToRemove.getPath());
} else {
for (Map.Entry entry : writeToRemove.getMerge()) {
Path path = entry.getKey();
this.visibleWrites = this.visibleWrites.removeWrite(writeToRemove.getPath().child(path));
}
}
return true;
}
}
/**
* Return a complete snapshot for the given path if there's visible write data at that path, else
* null. No server data is considered.
*/
public Node getCompleteWriteData(Path path) {
return this.visibleWrites.getCompleteNode(path);
}
/**
* Given optional, underlying server data, and an optional set of constraints (exclude some sets,
* include hidden writes), attempt to calculate a complete snapshot for the given path
*/
public Node calcCompleteEventCache(Path treePath, Node completeServerCache) {
return this.calcCompleteEventCache(treePath, completeServerCache, new ArrayList());
}
public Node calcCompleteEventCache(
Path treePath, Node completeServerCache, List writeIdsToExclude) {
return this.calcCompleteEventCache(treePath, completeServerCache, writeIdsToExclude, false);
}
public Node calcCompleteEventCache(
final Path treePath,
Node completeServerCache,
final List writeIdsToExclude,
final boolean includeHiddenWrites) {
if (writeIdsToExclude.isEmpty() && !includeHiddenWrites) {
Node shadowingNode = this.visibleWrites.getCompleteNode(treePath);
if (shadowingNode != null) {
return shadowingNode;
} else {
CompoundWrite subMerge = this.visibleWrites.childCompoundWrite(treePath);
if (subMerge.isEmpty()) {
return completeServerCache;
} else if (completeServerCache == null && !subMerge.hasCompleteWrite(Path.getEmptyPath())) {
// We wouldn't have a complete snapshot, since there's no underlying data and
// no complete
// shadow
return null;
} else {
Node layeredCache;
if (completeServerCache != null) {
layeredCache = completeServerCache;
} else {
layeredCache = EmptyNode.Empty();
}
return subMerge.apply(layeredCache);
}
}
} else {
CompoundWrite merge = this.visibleWrites.childCompoundWrite(treePath);
if (!includeHiddenWrites && merge.isEmpty()) {
return completeServerCache;
} else {
// If the server cache is null, and we don't have a complete cache, we need to
// return null
if (!includeHiddenWrites
&& completeServerCache == null
&& !merge.hasCompleteWrite(Path.getEmptyPath())) {
return null;
} else {
Predicate filter =
new Predicate() {
@Override
public boolean evaluate(UserWriteRecord write) {
return (write.isVisible() || includeHiddenWrites)
&& (!writeIdsToExclude.contains(write.getWriteId()))
&& (write.getPath().contains(treePath) || treePath.contains(write.getPath()));
}
};
Node layeredCache;
CompoundWrite mergeAtPath = WriteTree.layerTree(this.allWrites, filter, treePath);
layeredCache = completeServerCache != null ? completeServerCache : EmptyNode.Empty();
return mergeAtPath.apply(layeredCache);
}
}
}
}
/**
* With underlying server data, attempt to return a children node of children that we have
* complete data for. Used when creating new views, to pre-fill their complete event children
* snapshot.
*/
public Node calcCompleteEventChildren(Path treePath, Node completeServerChildren) {
Node completeChildren = EmptyNode.Empty();
Node topLevelSet = this.visibleWrites.getCompleteNode(treePath);
if (topLevelSet != null) {
if (!topLevelSet.isLeafNode()) {
// we're shadowing everything. Return the children.
for (NamedNode childEntry : topLevelSet) {
completeChildren =
completeChildren.updateImmediateChild(childEntry.getName(), childEntry.getNode());
}
}
return completeChildren;
} else {
// Layer any children we have on top of this
// We know we don't have a top-level set, so just enumerate existing children, and
// apply any
// updates
CompoundWrite merge = this.visibleWrites.childCompoundWrite(treePath);
for (NamedNode entry : completeServerChildren) {
Node node = merge.childCompoundWrite(new Path(entry.getName())).apply(entry.getNode());
completeChildren = completeChildren.updateImmediateChild(entry.getName(), node);
}
// Add any complete children we have from the set
for (NamedNode node : merge.getCompleteChildren()) {
completeChildren = completeChildren.updateImmediateChild(node.getName(), node.getNode());
}
return completeChildren;
}
}
/**
* Given that the underlying server data has updated, determine what, if anything, needs to be
* applied to the event cache.
*
* Possibilities:
*
*
1. No writes are shadowing. Events should be raised, the snap to be applied comes from the
* server data
*
*
2. Some write is completely shadowing. No events to be raised
*
*
3. Is partially shadowed. Events
*
*
Either existingEventSnap or existingServerSnap must exist
*/
public Node calcEventCacheAfterServerOverwrite(
Path treePath,
final Path childPath,
final Node existingEventSnap,
final Node existingServerSnap) {
assert existingEventSnap != null || existingServerSnap != null
: "Either existingEventSnap or existingServerSnap must exist";
Path path = treePath.child(childPath);
if (this.visibleWrites.hasCompleteWrite(path)) {
// At this point we can probably guarantee that we're in case 2, meaning no events
// May need to check visibility while doing the findRootMostValueAndPath call
return null;
} else {
// No complete shadowing. We're either partially shadowing or not shadowing at all.
CompoundWrite childMerge = this.visibleWrites.childCompoundWrite(path);
if (childMerge.isEmpty()) {
// We're not shadowing at all. Case 1
return existingServerSnap.getChild(childPath);
} else {
// This could be more efficient if the serverNode + updates doesn't change the
// eventSnap
// However this is tricky to find out, since user updates don't necessary change
// the server
// snap, e.g. priority updates on empty nodes, or deep deletes. Another special
// case is if
// the server adds nodes, but doesn't change any existing writes. It is therefore
// not enough
// to only check if the updates change the serverNode.
// Maybe check if the merge tree contains these special cases and only do a full
// overwrite
// in that case?
return childMerge.apply(existingServerSnap.getChild(childPath));
}
}
}
/**
* Returns a complete child for a given server snap after applying all user writes or null if
* there is no complete child for this ChildKey.
*/
public Node calcCompleteChild(Path treePath, ChildKey childKey, CacheNode existingServerSnap) {
Path path = treePath.child(childKey);
Node shadowingNode = this.visibleWrites.getCompleteNode(path);
if (shadowingNode != null) {
return shadowingNode;
} else {
if (existingServerSnap.isCompleteForChild(childKey)) {
CompoundWrite childMerge = this.visibleWrites.childCompoundWrite(path);
return childMerge.apply(existingServerSnap.getNode().getImmediateChild(childKey));
} else {
return null;
}
}
}
/**
* Returns a node if there is a complete overwrite for this path. More specifically, if there is a
* write at a higher path, this will return the child of that write relative to the write and this
* path. Returns null if there is no write at this path.
*/
public Node shadowingWrite(Path path) {
return this.visibleWrites.getCompleteNode(path);
}
/**
* This method is used when processing child remove events on a query. If we can, we pull in
* children that were outside the window, but may now be in the window.
*/
public NamedNode calcNextNodeAfterPost(
Path treePath, Node completeServerData, NamedNode post, boolean reverse, Index index) {
Node toIterate;
CompoundWrite merge = this.visibleWrites.childCompoundWrite(treePath);
Node shadowingNode = merge.getCompleteNode(Path.getEmptyPath());
if (shadowingNode != null) {
toIterate = shadowingNode;
} else if (completeServerData != null) {
toIterate = merge.apply(completeServerData);
} else {
// no children to iterate on
return null;
}
NamedNode currentNext = null;
for (NamedNode node : toIterate) {
if (index.compare(node, post, reverse) > 0
&& (currentNext == null || index.compare(node, currentNext, reverse) < 0)) {
currentNext = node;
}
}
return currentNext;
}
private boolean recordContainsPath(UserWriteRecord writeRecord, Path path) {
if (writeRecord.isOverwrite()) {
return writeRecord.getPath().contains(path);
} else {
for (Map.Entry entry : writeRecord.getMerge()) {
if (writeRecord.getPath().child(entry.getKey()).contains(path)) {
return true;
}
}
return false;
}
}
/** Re-layer the writes and merges into a tree so we can efficiently calculate event snapshots */
private void resetTree() {
this.visibleWrites =
WriteTree.layerTree(this.allWrites, WriteTree.DEFAULT_FILTER, Path.getEmptyPath());
if (this.allWrites.size() > 0) {
this.lastWriteId = this.allWrites.get(this.allWrites.size() - 1).getWriteId();
} else {
this.lastWriteId = -1L;
}
}
}