com.google.firebase.database.core.Repo Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of firebase-admin Show documentation
Show all versions of firebase-admin Show documentation
This is the official Firebase Admin Java SDK. Build extraordinary native JVM apps in
minutes with Firebase. The Firebase platform can power your app’s backend, user
authentication, static hosting, and more.
/*
* 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 static com.google.firebase.database.utilities.Utilities.hardAssert;
import com.google.firebase.database.DataSnapshot;
import com.google.firebase.database.DatabaseError;
import com.google.firebase.database.DatabaseException;
import com.google.firebase.database.DatabaseReference;
import com.google.firebase.database.FirebaseDatabase;
import com.google.firebase.database.InternalHelpers;
import com.google.firebase.database.MutableData;
import com.google.firebase.database.Transaction;
import com.google.firebase.database.ValueEventListener;
import com.google.firebase.database.annotations.NotNull;
import com.google.firebase.database.connection.HostInfo;
import com.google.firebase.database.connection.ListenHashProvider;
import com.google.firebase.database.connection.PersistentConnection;
import com.google.firebase.database.connection.RequestResultCallback;
import com.google.firebase.database.core.persistence.NoopPersistenceManager;
import com.google.firebase.database.core.persistence.PersistenceManager;
import com.google.firebase.database.core.utilities.Tree;
import com.google.firebase.database.core.view.Event;
import com.google.firebase.database.core.view.EventRaiser;
import com.google.firebase.database.core.view.QuerySpec;
import com.google.firebase.database.snapshot.ChildKey;
import com.google.firebase.database.snapshot.EmptyNode;
import com.google.firebase.database.snapshot.IndexedNode;
import com.google.firebase.database.snapshot.Node;
import com.google.firebase.database.snapshot.NodeUtilities;
import com.google.firebase.database.snapshot.RangeMerge;
import com.google.firebase.database.utilities.DefaultClock;
import com.google.firebase.database.utilities.OffsetClock;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Repo implements PersistentConnection.Delegate {
private static final String INTERRUPT_REASON = "repo_interrupt";
/**
* If a transaction does not succeed after 25 retries, we abort it. Among other things this ensure
* that if there's ever a bug causing a mismatch between client / server hashes for some data, we
* won't retry indefinitely.
*/
private static final int TRANSACTION_MAX_RETRIES = 25;
private static final String TRANSACTION_TOO_MANY_RETRIES = "maxretries";
private static final String TRANSACTION_OVERRIDE_BY_SET = "overriddenBySet";
private static final Logger logger = LoggerFactory.getLogger(Repo.class);
private final RepoInfo repoInfo;
private final OffsetClock serverClock = new OffsetClock(new DefaultClock(), 0);
private final PersistentConnection connection;
private final EventRaiser eventRaiser;
private final Context ctx;
private SnapshotHolder infoData;
private SparseSnapshotTree onDisconnect;
private Tree> transactionQueueTree;
private boolean hijackHash = false;
private long nextWriteId = 1;
private SyncTree infoSyncTree;
private SyncTree serverSyncTree;
private final FirebaseDatabase database;
private boolean loggedTransactionPersistenceWarning = false;
private long transactionOrder = 0;
Repo(RepoInfo repoInfo, Context ctx, FirebaseDatabase database) {
this.repoInfo = repoInfo;
this.ctx = ctx;
this.database = database;
this.eventRaiser = new EventRaiser(this.ctx);
HostInfo hostInfo = new HostInfo(repoInfo.host, repoInfo.namespace, repoInfo.secure);
connection = ctx.newPersistentConnection(hostInfo, this);
// Kick off any expensive additional initialization
scheduleNow(
new Runnable() {
@Override
public void run() {
deferredInitialization();
}
});
}
private static DatabaseError fromErrorCode(String optErrorCode, String optErrorReason) {
if (optErrorCode != null) {
return DatabaseError.fromStatus(optErrorCode, optErrorReason);
} else {
return null;
}
}
// Regarding the next three methods: scheduleNow, schedule, and postEvent:
// Please use these methods rather than accessing the context directly. This ensures that the
// context is correctly re-initialized if it was previously shut down. In practice, this means
// that when a task is submitted, we will guarantee at least one thread in the core pool for the
// run loop.
/**
* Defers any initialization that is potentially expensive (e.g. disk access) and must be run on
* the run loop
*/
private void deferredInitialization() {
this.ctx.getAuthTokenProvider()
.addTokenChangeListener(
new AuthTokenProvider.TokenChangeListener() {
@Override
public void onTokenChange(String token) {
logger.debug("Auth token changed, triggering auth token refresh");
connection.refreshAuthToken(token);
}
});
// Open connection now so that by the time we are connected the deferred init has run
// This relies on the fact that all callbacks run on repo's runloop.
connection.initialize();
PersistenceManager persistenceManager = ctx.getPersistenceManager(repoInfo.host);
infoData = new SnapshotHolder();
onDisconnect = new SparseSnapshotTree();
transactionQueueTree = new Tree<>();
infoSyncTree = new SyncTree(new NoopPersistenceManager(),
new SyncTree.ListenProvider() {
@Override
public void startListening(
final QuerySpec query,
Tag tag,
final ListenHashProvider hash,
final SyncTree.CompletionListener onComplete) {
scheduleNow(new Runnable() {
@Override
public void run() {
// This is possibly a hack, but we have different semantics for .info
// endpoints. We don't raise null events on initial data...
final Node node = infoData.getNode(query.getPath());
if (!node.isEmpty()) {
List extends Event> infoEvents =
infoSyncTree.applyServerOverwrite(query.getPath(), node);
postEvents(infoEvents);
onComplete.onListenComplete(null);
}
}
});
}
@Override
public void stopListening(QuerySpec query, Tag tag) {}
});
serverSyncTree = new SyncTree(persistenceManager,
new SyncTree.ListenProvider() {
@Override
public void startListening(
QuerySpec query,
Tag tag,
ListenHashProvider hash,
final SyncTree.CompletionListener onListenComplete) {
connection.listen(
query.getPath().asList(),
query.getParams().getWireProtocolParams(),
hash,
tag != null ? tag.getTagNumber() : null,
new RequestResultCallback() {
@Override
public void onRequestResult(String optErrorCode, String optErrorMessage) {
DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage);
List extends Event> events = onListenComplete.onListenComplete(error);
postEvents(events);
}
});
}
@Override
public void stopListening(QuerySpec query, Tag tag) {
connection.unlisten(
query.getPath().asList(), query.getParams().getWireProtocolParams());
}
});
restoreWrites(persistenceManager);
updateInfo(Constants.DOT_INFO_AUTHENTICATED, false);
updateInfo(Constants.DOT_INFO_CONNECTED, false);
}
private void restoreWrites(PersistenceManager persistenceManager) {
// No-op (Admin SDK does not support persistence)
}
public FirebaseDatabase getDatabase() {
return this.database;
}
@Override
public String toString() {
return repoInfo.toString();
}
public RepoInfo getRepoInfo() {
return this.repoInfo;
}
public void scheduleNow(Runnable r) {
InternalHelpers.checkNotDestroyed(this);
ctx.requireStarted();
ctx.getRunLoop().scheduleNow(r);
}
private void postEvent(Runnable r) {
InternalHelpers.checkNotDestroyed(this);
ctx.requireStarted();
ctx.getEventTarget().postEvent(r);
}
private void postEvents(final List extends Event> events) {
if (!events.isEmpty()) {
this.eventRaiser.raiseEvents(events);
}
}
public long getServerTime() {
return serverClock.millis();
}
boolean hasListeners() {
return !(this.infoSyncTree.isEmpty() && this.serverSyncTree.isEmpty());
}
// PersistentConnection.Delegate methods
@SuppressWarnings("unchecked") // For the cast on rawMergedData
@Override
public void onDataUpdate(
List pathSegments, Object message, boolean isMerge, Long optTag) {
Path path = new Path(pathSegments);
logger.debug("onDataUpdate: {} {}", path, message);
List extends Event> events;
try {
if (optTag != null) {
Tag tag = new Tag(optTag);
if (isMerge) {
Map taggedChildren = new HashMap<>();
Map rawMergeData = (Map) message;
for (Map.Entry entry : rawMergeData.entrySet()) {
Node newChildNode = NodeUtilities.NodeFromJSON(entry.getValue());
taggedChildren.put(new Path(entry.getKey()), newChildNode);
}
events = this.serverSyncTree.applyTaggedQueryMerge(path, taggedChildren, tag);
} else {
Node taggedSnap = NodeUtilities.NodeFromJSON(message);
events = this.serverSyncTree.applyTaggedQueryOverwrite(path, taggedSnap, tag);
}
} else if (isMerge) {
Map changedChildren = new HashMap<>();
Map rawMergeData = (Map) message;
for (Map.Entry entry : rawMergeData.entrySet()) {
Node newChildNode = NodeUtilities.NodeFromJSON(entry.getValue());
changedChildren.put(new Path(entry.getKey()), newChildNode);
}
events = this.serverSyncTree.applyServerMerge(path, changedChildren);
} else {
Node snap = NodeUtilities.NodeFromJSON(message);
events = this.serverSyncTree.applyServerOverwrite(path, snap);
}
if (events.size() > 0) {
// Since we have a listener outstanding for each transaction, receiving any events
// is a proxy for some change having occurred.
this.rerunTransactions(path);
}
postEvents(events);
} catch (DatabaseException e) {
logger.error("Firebase internal error", e);
}
}
@Override
public void onRangeMergeUpdate(
List pathSegments,
List merges,
Long tagNumber) {
Path path = new Path(pathSegments);
logger.debug("onRangeMergeUpdate: {} {}", path, merges);
List parsedMerges = new ArrayList<>(merges.size());
for (com.google.firebase.database.connection.RangeMerge merge : merges) {
parsedMerges.add(new RangeMerge(merge));
}
List extends Event> events;
if (tagNumber != null) {
events = this.serverSyncTree.applyTaggedRangeMerges(path, parsedMerges, new Tag(tagNumber));
} else {
events = this.serverSyncTree.applyServerRangeMerges(path, parsedMerges);
}
if (events.size() > 0) {
// Since we have a listener outstanding for each transaction, receiving any events
// is a proxy for some change having occurred.
this.rerunTransactions(path);
}
postEvents(events);
}
void callOnComplete(
final DatabaseReference.CompletionListener onComplete,
final DatabaseError error,
final Path path) {
if (onComplete != null) {
final DatabaseReference ref;
ChildKey last = path.getBack();
if (last != null && last.isPriorityChildName()) {
ref = InternalHelpers.createReference(this, path.getParent());
} else {
ref = InternalHelpers.createReference(this, path);
}
postEvent(
new Runnable() {
@Override
public void run() {
onComplete.onComplete(error, ref);
}
});
}
}
private void ackWriteAndRerunTransactions(long writeId, Path path, DatabaseError error) {
if (error != null && error.getCode() == DatabaseError.WRITE_CANCELED) {
// This write was already removed, we just need to ignore it...
} else {
boolean success = error == null;
List extends Event> clearEvents =
serverSyncTree.ackUserWrite(writeId, !success, /*persist=*/ true, serverClock);
if (clearEvents.size() > 0) {
rerunTransactions(path);
}
postEvents(clearEvents);
}
}
public void setValue(
final Path path,
Node newValueUnresolved,
final DatabaseReference.CompletionListener onComplete) {
logger.debug("set: {} {}", path, newValueUnresolved);
Map serverValues = ServerValues.generateServerValues(serverClock);
Node newValue = ServerValues.resolveDeferredValueSnapshot(newValueUnresolved, serverValues);
final long writeId = this.getNextWriteId();
List extends Event> events =
this.serverSyncTree.applyUserOverwrite(
path, newValueUnresolved, newValue, writeId, /*visible=*/ true, /*persist=*/ true);
this.postEvents(events);
connection.put(
path.asList(),
newValueUnresolved.getValue(true),
new RequestResultCallback() {
@Override
public void onRequestResult(String optErrorCode, String optErrorMessage) {
DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage);
warnIfWriteFailed("setValue", path, error);
ackWriteAndRerunTransactions(writeId, path, error);
callOnComplete(onComplete, error, path);
}
});
Path affectedPath = abortTransactions(path, DatabaseError.OVERRIDDEN_BY_SET);
this.rerunTransactions(affectedPath);
}
public void updateChildren(
final Path path,
CompoundWrite updates,
final DatabaseReference.CompletionListener onComplete,
Map unParsedUpdates) {
logger.debug("update: {} {}", path, unParsedUpdates);
if (updates.isEmpty()) {
logger.debug("update called with no changes. No-op");
// dispatch on complete
callOnComplete(onComplete, null, path);
return;
}
// Start with our existing data and merge each child into it.
Map serverValues = ServerValues.generateServerValues(serverClock);
CompoundWrite resolved = ServerValues.resolveDeferredValueMerge(updates, serverValues);
final long writeId = this.getNextWriteId();
List extends Event> events =
this.serverSyncTree.applyUserMerge(path, updates, resolved, writeId, /*persist=*/ true);
this.postEvents(events);
// TODO: DatabaseReference.CompleteionListener isn't really appropriate (the DatabaseReference
// param is meaningless).
connection.merge(
path.asList(),
unParsedUpdates,
new RequestResultCallback() {
@Override
public void onRequestResult(String optErrorCode, String optErrorMessage) {
DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage);
warnIfWriteFailed("updateChildren", path, error);
ackWriteAndRerunTransactions(writeId, path, error);
callOnComplete(onComplete, error, path);
}
});
for (Entry update : updates) {
Path pathFromRoot = path.child(update.getKey());
Path affectedPath = abortTransactions(pathFromRoot, DatabaseError.OVERRIDDEN_BY_SET);
rerunTransactions(affectedPath);
}
}
public void purgeOutstandingWrites() {
logger.debug("Purging writes");
List extends Event> events = serverSyncTree.removeAllWrites();
postEvents(events);
// Abort any transactions
abortTransactions(Path.getEmptyPath(), DatabaseError.WRITE_CANCELED);
// Remove outstanding writes from connection
connection.purgeOutstandingWrites();
}
public void removeEventCallback(@NotNull EventRegistration eventRegistration) {
// These are guaranteed not to raise events, since we're not passing in a cancelError. However,
// we can future-proof a little bit by handling the return values anyways.
List events;
if (Constants.DOT_INFO.equals(eventRegistration.getQuerySpec().getPath().getFront())) {
events = infoSyncTree.removeEventRegistration(eventRegistration);
} else {
events = serverSyncTree.removeEventRegistration(eventRegistration);
}
this.postEvents(events);
}
public void onDisconnectSetValue(
final Path path, final Node newValue, final DatabaseReference.CompletionListener onComplete) {
connection.onDisconnectPut(
path.asList(),
newValue.getValue(true),
new RequestResultCallback() {
@Override
public void onRequestResult(String optErrorCode, String optErrorMessage) {
DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage);
warnIfWriteFailed("onDisconnect().setValue", path, error);
if (error == null) {
onDisconnect.remember(path, newValue);
}
callOnComplete(onComplete, error, path);
}
});
}
public void onDisconnectUpdate(
final Path path,
final Map newChildren,
final DatabaseReference.CompletionListener listener,
Map unParsedUpdates) {
connection.onDisconnectMerge(
path.asList(),
unParsedUpdates,
new RequestResultCallback() {
@Override
public void onRequestResult(String optErrorCode, String optErrorMessage) {
DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage);
warnIfWriteFailed("onDisconnect().updateChildren", path, error);
if (error == null) {
for (Map.Entry entry : newChildren.entrySet()) {
onDisconnect.remember(path.child(entry.getKey()), entry.getValue());
}
}
callOnComplete(listener, error, path);
}
});
}
public void onDisconnectCancel(
final Path path, final DatabaseReference.CompletionListener onComplete) {
connection.onDisconnectCancel(
path.asList(),
new RequestResultCallback() {
@Override
public void onRequestResult(String optErrorCode, String optErrorMessage) {
DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage);
if (error == null) {
onDisconnect.forget(path);
}
callOnComplete(onComplete, error, path);
}
});
}
@Override
public void onConnect() {
onServerInfoUpdate(Constants.DOT_INFO_CONNECTED, true);
}
@Override
public void onDisconnect() {
onServerInfoUpdate(Constants.DOT_INFO_CONNECTED, false);
runOnDisconnectEvents();
}
@Override
public void onAuthStatus(boolean authOk) {
onServerInfoUpdate(Constants.DOT_INFO_AUTHENTICATED, authOk);
}
private void onServerInfoUpdate(ChildKey key, Object value) {
updateInfo(key, value);
}
@Override
public void onServerInfoUpdate(Map updates) {
for (Map.Entry entry : updates.entrySet()) {
updateInfo(ChildKey.fromString(entry.getKey()), entry.getValue());
}
}
void interrupt() {
connection.interrupt(INTERRUPT_REASON);
}
void resume() {
InternalHelpers.checkNotDestroyed(this);
connection.resume(INTERRUPT_REASON);
}
public void addEventCallback(@NotNull EventRegistration eventRegistration) {
List extends Event> events;
ChildKey front = eventRegistration.getQuerySpec().getPath().getFront();
if (front != null && front.equals(Constants.DOT_INFO)) {
events = this.infoSyncTree.addEventRegistration(eventRegistration);
} else {
events = this.serverSyncTree.addEventRegistration(eventRegistration);
}
this.postEvents(events);
}
public void keepSynced(QuerySpec query, boolean keep) {
assert query.getPath().isEmpty() || !query.getPath().getFront().equals(Constants.DOT_INFO);
serverSyncTree.keepSynced(query, keep);
}
// Transaction code
PersistentConnection getConnection() {
return connection;
}
private void updateInfo(ChildKey childKey, Object value) {
if (childKey.equals(Constants.DOT_INFO_SERVERTIME_OFFSET)) {
serverClock.setOffset((Long) value);
}
Path path = new Path(Constants.DOT_INFO, childKey);
try {
Node node = NodeUtilities.NodeFromJSON(value);
infoData.update(path, node);
List extends Event> events = this.infoSyncTree.applyServerOverwrite(path, node);
this.postEvents(events);
} catch (DatabaseException e) {
logger.error("Failed to parse info update", e);
}
}
private long getNextWriteId() {
return this.nextWriteId++;
}
private void runOnDisconnectEvents() {
Map serverValues = ServerValues.generateServerValues(serverClock);
SparseSnapshotTree resolvedTree =
ServerValues.resolveDeferredValueTree(this.onDisconnect, serverValues);
final List events = new ArrayList<>();
resolvedTree.forEachTree(
Path.getEmptyPath(),
new SparseSnapshotTree.SparseSnapshotTreeVisitor() {
@Override
public void visitTree(Path prefixPath, Node node) {
events.addAll(serverSyncTree.applyServerOverwrite(prefixPath, node));
Path affectedPath = abortTransactions(prefixPath, DatabaseError.OVERRIDDEN_BY_SET);
rerunTransactions(affectedPath);
}
});
onDisconnect = new SparseSnapshotTree();
this.postEvents(events);
}
private void warnIfWriteFailed(String writeType, Path path, DatabaseError error) {
// DATA_STALE is a normal, expected error during transaction processing.
if (error != null
&& !(error.getCode() == DatabaseError.DATA_STALE
|| error.getCode() == DatabaseError.WRITE_CANCELED)) {
logger.warn(writeType + " at " + path.toString() + " failed: " + error.toString());
}
}
public void startTransaction(Path path, final Transaction.Handler handler, boolean applyLocally) {
logger.debug("transaction: {}", path);
if (this.ctx.isPersistenceEnabled() && !loggedTransactionPersistenceWarning) {
loggedTransactionPersistenceWarning = true;
logger.info(
"runTransaction() usage detected while persistence is enabled. Please be aware that "
+ "transactions *will not* be persisted across database restarts. See "
+ "https://www.firebase.com/docs/android/guide/offline-capabilities.html"
+ "#section-handling-transactions-offline for more details.");
}
// make sure we're listening on this node
// Note: we can't do this asynchronously. To preserve event ordering,
// it has to be done in this block. This is ok, this block is
// guaranteed to be our own event loop
DatabaseReference watchRef = InternalHelpers.createReference(this, path);
ValueEventListener listener =
new ValueEventListener() {
@Override
public void onDataChange(DataSnapshot snapshot) {
// No-op. We don't care, this is just to make sure we have a listener outstanding
}
@Override
public void onCancelled(DatabaseError error) {
// Also a no-op? We'll cancel the transaction in this case
}
};
addEventCallback(new ValueEventRegistration(this, listener, watchRef.getSpec()));
TransactionData transaction =
new TransactionData(
path,
handler,
listener,
TransactionStatus.INITIALIZING,
applyLocally,
nextTransactionOrder());
// Run transaction initially.
Node currentState = this.getLatestState(path);
transaction.currentInputSnapshot = currentState;
MutableData mutableCurrent = InternalHelpers.createMutableData(currentState);
DatabaseError error = null;
Transaction.Result result;
try {
result = handler.doTransaction(mutableCurrent);
if (result == null) {
throw new NullPointerException("Transaction returned null as result");
}
} catch (Throwable e) {
error = DatabaseError.fromException(e);
result = Transaction.abort();
}
if (!result.isSuccess()) {
// Abort the transaction
transaction.currentOutputSnapshotRaw = null;
transaction.currentOutputSnapshotResolved = null;
final DatabaseError innerClassError = error;
final DataSnapshot snap =
InternalHelpers.createDataSnapshot(
watchRef, IndexedNode.from(transaction.currentInputSnapshot));
postEvent(
new Runnable() {
@Override
public void run() {
runTransactionOnComplete(handler, innerClassError, false, snap);
}
});
} else {
// Mark as run and add to our queue.
transaction.status = TransactionStatus.RUN;
Tree> queueNode = transactionQueueTree.subTree(path);
List nodeQueue = queueNode.getValue();
if (nodeQueue == null) {
nodeQueue = new ArrayList<>();
}
nodeQueue.add(transaction);
queueNode.setValue(nodeQueue);
Map serverValues = ServerValues.generateServerValues(serverClock);
Node newNodeUnresolved = result.getNode();
Node newNode = ServerValues.resolveDeferredValueSnapshot(newNodeUnresolved, serverValues);
transaction.currentOutputSnapshotRaw = newNodeUnresolved;
transaction.currentOutputSnapshotResolved = newNode;
transaction.currentWriteId = this.getNextWriteId();
List extends Event> events =
this.serverSyncTree.applyUserOverwrite(
path,
newNodeUnresolved,
newNode,
transaction.currentWriteId, /*visible=*/
applyLocally, /*persist=*/
false);
this.postEvents(events);
sendAllReadyTransactions();
}
}
private Node getLatestState(Path path) {
return this.getLatestState(path, new ArrayList());
}
private Node getLatestState(Path path, List excudeSets) {
Node state = this.serverSyncTree.calcCompleteEventCache(path, excudeSets);
if (state == null) {
state = EmptyNode.Empty();
}
return state;
}
public void setHijackHash(boolean hijackHash) {
this.hijackHash = hijackHash;
}
private void sendAllReadyTransactions() {
Tree> node = transactionQueueTree;
pruneCompletedTransactions(node);
sendReadyTransactions(node);
}
private void sendReadyTransactions(Tree> node) {
List queue = node.getValue();
if (queue != null) {
queue = buildTransactionQueue(node);
assert queue.size() > 0; // Sending zero length transaction queue
Boolean allRun = true;
for (TransactionData transaction : queue) {
if (transaction.status != TransactionStatus.RUN) {
allRun = false;
break;
}
}
// If they're all run (and not sent), we can send them. Else, we must wait.
if (allRun) {
sendTransactionQueue(queue, node.getPath());
}
} else if (node.hasChildren()) {
node.forEachChild(
new Tree.TreeVisitor>() {
@Override
public void visitTree(Tree> tree) {
sendReadyTransactions(tree);
}
});
}
}
private void sendTransactionQueue(final List queue, final Path path) {
// Mark transactions as sent and increment retry count!
List setsToIgnore = new ArrayList<>();
for (TransactionData txn : queue) {
setsToIgnore.add(txn.currentWriteId);
}
Node latestState = this.getLatestState(path, setsToIgnore);
Node snapToSend = latestState;
String latestHash = "badhash";
if (!hijackHash) {
latestHash = latestState.getHash();
}
for (TransactionData txn : queue) {
assert txn.status
== TransactionStatus.RUN; // sendTransactionQueue: items in queue should all be run.'
txn.status = TransactionStatus.SENT;
txn.retryCount++;
Path relativePath = Path.getRelative(path, txn.path);
// If we've gotten to this point, the output snapshot must be defined.
snapToSend = snapToSend.updateChild(relativePath, txn.currentOutputSnapshotRaw);
}
Object dataToSend = snapToSend.getValue(true);
final Repo repo = this;
// Send the put.
connection.compareAndPut(
path.asList(),
dataToSend,
latestHash,
new RequestResultCallback() {
@Override
public void onRequestResult(String optErrorCode, String optErrorMessage) {
DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage);
warnIfWriteFailed("Transaction", path, error);
List events = new ArrayList<>();
if (error == null) {
List callbacks = new ArrayList<>();
for (final TransactionData txn : queue) {
txn.status = TransactionStatus.COMPLETED;
events.addAll(
serverSyncTree.ackUserWrite(
txn.currentWriteId, /*revert=*/ false, /*persist=*/ false, serverClock));
// We never unset the output snapshot, and given that this
// transaction is complete, it should be set
Node node = txn.currentOutputSnapshotResolved;
final DataSnapshot snap =
InternalHelpers.createDataSnapshot(
InternalHelpers.createReference(repo, txn.path), IndexedNode.from(node));
callbacks.add(
new Runnable() {
@Override
public void run() {
runTransactionOnComplete(txn.handler, null, true, snap);
}
});
// Remove the outstanding value listener that we added
removeEventCallback(
new ValueEventRegistration(
Repo.this,
txn.outstandingListener,
QuerySpec.defaultQueryAtPath(txn.path)));
}
// Now remove the completed transactions
pruneCompletedTransactions(transactionQueueTree.subTree(path));
// There may be pending transactions that we can now send
sendAllReadyTransactions();
repo.postEvents(events);
// Finally, run the callbacks
for (Runnable callback : callbacks) {
postEvent(callback);
}
} else {
// transactions are no longer sent. Update their status appropriately
if (error.getCode() == DatabaseError.DATA_STALE) {
for (TransactionData transaction : queue) {
if (transaction.status == TransactionStatus.SENT_NEEDS_ABORT) {
transaction.status = TransactionStatus.NEEDS_ABORT;
} else {
transaction.status = TransactionStatus.RUN;
}
}
} else {
for (TransactionData transaction : queue) {
transaction.status = TransactionStatus.NEEDS_ABORT;
transaction.abortReason = error;
}
}
// since we reverted mergedData, we should re-run any remaining
// transactions and raise events
rerunTransactions(path);
}
}
});
}
private void pruneCompletedTransactions(Tree> node) {
List queue = node.getValue();
if (queue != null) {
int i = 0;
while (i < queue.size()) {
TransactionData transaction = queue.get(i);
if (transaction.status == TransactionStatus.COMPLETED) {
queue.remove(i);
} else {
i++;
}
}
if (queue.size() > 0) {
node.setValue(queue);
} else {
node.setValue(null);
}
}
node.forEachChild(
new Tree.TreeVisitor>() {
@Override
public void visitTree(Tree> tree) {
pruneCompletedTransactions(tree);
}
});
}
private long nextTransactionOrder() {
return transactionOrder++;
}
private Path rerunTransactions(Path changedPath) {
Tree> rootMostTransactionNode = getAncestorTransactionNode(changedPath);
Path path = rootMostTransactionNode.getPath();
List queue = buildTransactionQueue(rootMostTransactionNode);
rerunTransactionQueue(queue, path);
return path;
}
private void rerunTransactionQueue(List queue, Path path) {
if (queue.isEmpty()) {
return; // Nothing to do!
}
// Queue up the callbacks and fire them after cleaning up all of our transaction state, since
// the callback could trigger more transactions or sets
List callbacks = new ArrayList<>();
// Ignore, by default, all of the sets in this queue, since we're re-running all of them.
// However, we want to include the results of new sets triggered as part of this re-run, so we
// don't want to ignore a range, just these specific sets.
List setsToIgnore = new ArrayList<>();
for (TransactionData transaction : queue) {
setsToIgnore.add(transaction.currentWriteId);
}
for (final TransactionData transaction : queue) {
Path relativePath = Path.getRelative(path, transaction.path);
boolean abortTransaction = false;
DatabaseError abortReason = null;
List events = new ArrayList<>();
assert relativePath != null; // rerunTransactionQueue: relativePath should not be null.
if (transaction.status == TransactionStatus.NEEDS_ABORT) {
abortTransaction = true;
abortReason = transaction.abortReason;
if (abortReason.getCode() != DatabaseError.WRITE_CANCELED) {
events.addAll(
serverSyncTree.ackUserWrite(
transaction.currentWriteId, /*revert=*/ true, /*persist=*/ false, serverClock));
}
} else if (transaction.status == TransactionStatus.RUN) {
if (transaction.retryCount >= TRANSACTION_MAX_RETRIES) {
abortTransaction = true;
abortReason = DatabaseError.fromStatus(TRANSACTION_TOO_MANY_RETRIES);
events.addAll(
serverSyncTree.ackUserWrite(
transaction.currentWriteId, /*revert=*/ true, /*persist=*/ false, serverClock));
} else {
// This code reruns a transaction
Node currentNode = this.getLatestState(transaction.path, setsToIgnore);
transaction.currentInputSnapshot = currentNode;
MutableData mutableCurrent = InternalHelpers.createMutableData(currentNode);
DatabaseError error = null;
Transaction.Result result;
try {
result = transaction.handler.doTransaction(mutableCurrent);
} catch (Throwable e) {
error = DatabaseError.fromException(e);
result = Transaction.abort();
}
if (result.isSuccess()) {
final Long oldWriteId = transaction.currentWriteId;
Map serverValues = ServerValues.generateServerValues(serverClock);
Node newDataNode = result.getNode();
Node newNodeResolved =
ServerValues.resolveDeferredValueSnapshot(newDataNode, serverValues);
transaction.currentOutputSnapshotRaw = newDataNode;
transaction.currentOutputSnapshotResolved = newNodeResolved;
transaction.currentWriteId = this.getNextWriteId();
// Mutates setsToIgnore in place
setsToIgnore.remove(oldWriteId);
events.addAll(
serverSyncTree.applyUserOverwrite(
transaction.path,
newDataNode,
newNodeResolved,
transaction.currentWriteId,
transaction.applyLocally, /*persist=*/
false));
events.addAll(
serverSyncTree.ackUserWrite(
oldWriteId, /*revert=*/ true, /*persist=*/ false, serverClock));
} else {
// The user aborted the transaction. It's not an error, so we don't need to send them
// one
abortTransaction = true;
abortReason = error;
events.addAll(
serverSyncTree.ackUserWrite(
transaction.currentWriteId, /*revert=*/ true, /*persist=*/ false, serverClock));
}
}
}
this.postEvents(events);
if (abortTransaction) {
// Abort
transaction.status = TransactionStatus.COMPLETED;
final DatabaseReference ref = InternalHelpers.createReference(this, transaction.path);
// We set this field immediately, so it's safe to cast to an actual snapshot
Node lastInput = transaction.currentInputSnapshot;
// TODO: In the future, perhaps this should just be KeyIndex?
final DataSnapshot snapshot =
InternalHelpers.createDataSnapshot(ref, IndexedNode.from(lastInput));
// Removing a callback can trigger pruning which can muck with mergedData/visibleData (as it
// prunes data). So defer removing the callback until later.
this.scheduleNow(
new Runnable() {
@Override
public void run() {
removeEventCallback(
new ValueEventRegistration(
Repo.this,
transaction.outstandingListener,
QuerySpec.defaultQueryAtPath(transaction.path)));
}
});
final DatabaseError callbackError = abortReason;
callbacks.add(
new Runnable() {
@Override
public void run() {
runTransactionOnComplete(transaction.handler, callbackError, false, snapshot);
}
});
}
}
// Clean up completed transactions.
pruneCompletedTransactions(transactionQueueTree);
// Now fire callbacks, now that we're in a good, known state.
for (Runnable callback : callbacks) {
postEvent(callback);
}
// Try to send the transaction result to the server.
sendAllReadyTransactions();
}
private Tree> getAncestorTransactionNode(Path path) {
Tree> transactionNode = transactionQueueTree;
while (!path.isEmpty() && transactionNode.getValue() == null) {
transactionNode = transactionNode.subTree(new Path(path.getFront()));
path = path.popFront();
}
return transactionNode;
}
private List buildTransactionQueue(Tree> transactionNode) {
List queue = new ArrayList<>();
aggregateTransactionQueues(queue, transactionNode);
Collections.sort(queue);
return queue;
}
private void aggregateTransactionQueues(
final List queue, Tree> node) {
List childQueue = node.getValue();
if (childQueue != null) {
queue.addAll(childQueue);
}
node.forEachChild(
new Tree.TreeVisitor>() {
@Override
public void visitTree(Tree> tree) {
aggregateTransactionQueues(queue, tree);
}
});
}
private Path abortTransactions(Path path, final int reason) {
Path affectedPath = getAncestorTransactionNode(path).getPath();
logger.debug("Aborting transactions for path: {}. Affected: {}", path, affectedPath);
Tree> transactionNode = transactionQueueTree.subTree(path);
transactionNode.forEachAncestor(
new Tree.TreeFilter>() {
@Override
public boolean filterTreeNode(Tree> tree) {
abortTransactionsAtNode(tree, reason);
return false;
}
});
abortTransactionsAtNode(transactionNode, reason);
transactionNode.forEachDescendant(
new Tree.TreeVisitor>() {
@Override
public void visitTree(Tree> tree) {
abortTransactionsAtNode(tree, reason);
}
});
return affectedPath;
}
private void abortTransactionsAtNode(Tree> node, int reason) {
List queue = node.getValue();
List events = new ArrayList<>();
if (queue != null) {
List callbacks = new ArrayList<>();
final DatabaseError abortError;
if (reason == DatabaseError.OVERRIDDEN_BY_SET) {
abortError = DatabaseError.fromStatus(TRANSACTION_OVERRIDE_BY_SET);
} else {
hardAssert(
reason == DatabaseError.WRITE_CANCELED, "Unknown transaction abort reason: " + reason);
abortError = DatabaseError.fromCode(DatabaseError.WRITE_CANCELED);
}
int lastSent = -1;
for (int i = 0; i < queue.size(); ++i) {
final TransactionData transaction = queue.get(i);
if (transaction.status == TransactionStatus.SENT_NEEDS_ABORT) {
// No-op. Already marked
} else if (transaction.status == TransactionStatus.SENT) {
assert lastSent == i - 1; // All SENT items should be at beginning of queue.
lastSent = i;
// Mark transaction for abort when it comes back.
transaction.status = TransactionStatus.SENT_NEEDS_ABORT;
transaction.abortReason = abortError;
} else {
assert transaction.status
== TransactionStatus.RUN; // Unexpected transaction status in abort
// We can abort this immediately.
removeEventCallback(
new ValueEventRegistration(
Repo.this,
transaction.outstandingListener,
QuerySpec.defaultQueryAtPath(transaction.path)));
if (reason == DatabaseError.OVERRIDDEN_BY_SET) {
events.addAll(
serverSyncTree.ackUserWrite(
transaction.currentWriteId, /*revert=*/ true, /*persist=*/ false, serverClock));
} else {
hardAssert(
reason == DatabaseError.WRITE_CANCELED,
"Unknown transaction abort reason: " + reason);
// If it was cancelled, it was already removed from the sync tree
}
callbacks.add(
new Runnable() {
@Override
public void run() {
runTransactionOnComplete(transaction.handler, abortError, false, null);
}
});
}
}
if (lastSent == -1) {
// We're not waiting for any sent transactions. We can clear the queue
node.setValue(null);
} else {
// Remove the transactions we aborted
node.setValue(queue.subList(0, lastSent + 1));
}
// Now fire the callbacks.
this.postEvents(events);
for (Runnable r : callbacks) {
postEvent(r);
}
}
}
private void runTransactionOnComplete(Transaction.Handler handler, DatabaseError error,
boolean committed, DataSnapshot snapshot) {
try {
handler.onComplete(error, committed, snapshot);
} catch (Exception e) {
logger.error("Exception in transaction onComplete callback", e);
}
}
// Package private for testing purposes only
SyncTree getServerSyncTree() {
return serverSyncTree;
}
// Package private for testing purposes only
SyncTree getInfoSyncTree() {
return infoSyncTree;
}
private enum TransactionStatus {
INITIALIZING,
// We've run the transaction and updated transactionResultData_ with the result, but it isn't
// currently sent to the server.
// A transaction will go from RUN -> SENT -> RUN if it comes back from the server as rejected
// due to mismatched hash.
RUN,
// We've run the transaction and sent it to the server and it's currently outstanding (hasn't
// come back as accepted or rejected yet).
SENT,
// Temporary state used to mark completed transactions (whether successful or aborted). The
// transaction will be removed when we get a chance to prune completed ones.
COMPLETED,
// Used when an already-sent transaction needs to be aborted (e.g. due to a conflicting set()
// call that was made). If it comes back as unsuccessful, we'll abort it.
SENT_NEEDS_ABORT,
// Temporary state used to mark transactions that need to be aborted.
NEEDS_ABORT
}
private static class TransactionData implements Comparable {
private Path path;
private Transaction.Handler handler;
private ValueEventListener outstandingListener;
private TransactionStatus status;
private long order;
private boolean applyLocally;
private int retryCount;
private DatabaseError abortReason;
private long currentWriteId;
private Node currentInputSnapshot;
private Node currentOutputSnapshotRaw;
private Node currentOutputSnapshotResolved;
private TransactionData(
Path path,
Transaction.Handler handler,
ValueEventListener outstandingListener,
TransactionStatus status,
boolean applyLocally,
long order) {
this.path = path;
this.handler = handler;
this.outstandingListener = outstandingListener;
this.status = status;
this.retryCount = 0;
this.applyLocally = applyLocally;
this.order = order;
this.abortReason = null;
this.currentInputSnapshot = null;
this.currentOutputSnapshotRaw = null;
this.currentOutputSnapshotResolved = null;
}
@Override
public int compareTo(TransactionData o) {
return Long.compare(order, o.order);
}
}
}