Skip to content

Prepare shutdown to be a public api. #589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
11 commits merged into from
Jul 17, 2019
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollection;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollectionWithDocs;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testDocument;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirebaseApp;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirestore;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitForException;
Expand All @@ -30,6 +31,7 @@
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
Expand All @@ -38,6 +40,7 @@
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.TaskCompletionSource;
import com.google.firebase.FirebaseApp;
import com.google.firebase.Timestamp;
import com.google.firebase.firestore.FirebaseFirestoreException.Code;
import com.google.firebase.firestore.Query.Direction;
Expand Down Expand Up @@ -1023,4 +1026,74 @@ public void testClearPersistenceWhileRunningFails() {
FirebaseFirestoreException firestoreException = (FirebaseFirestoreException) e;
assertEquals(Code.FAILED_PRECONDITION, firestoreException.getCode());
}

@Test
public void testRestartFirestoreLeadsToNewInstance() {
FirebaseApp app = testFirebaseApp();
FirebaseFirestore instance = FirebaseFirestore.getInstance(app);
FirebaseFirestore sameInstance = FirebaseFirestore.getInstance(app);

assertSame(instance, sameInstance);
waitFor(instance.document("abc/123").set(Collections.singletonMap("field", 100L)));

instance.shutdown();
FirebaseFirestore newInstance = FirebaseFirestore.getInstance(app);

// Verify new instance works.
DocumentSnapshot doc = waitFor(newInstance.document("abc/123").get());
assertEquals(doc.get("field"), 100L);
waitFor(newInstance.document("abc/123").delete());

// Verify it is different instance.
assertNotSame(instance, newInstance);
}

@Test
public void testAppDeleteLeadsToFirestoreShutdown() {
FirebaseApp app = testFirebaseApp();
FirebaseFirestore instance = FirebaseFirestore.getInstance(app);
waitFor(instance.document("abc/123").set(Collections.singletonMap("Field", 100)));

app.delete();

assertTrue(instance.getClient().isShutdown());
}

@Test
public void testNewOperationThrowsAfterFirestoreShutdown() {
FirebaseFirestore instance = testFirestore();
DocumentReference reference = instance.document("abc/123");
waitFor(reference.set(Collections.singletonMap("Field", 100)));

instance.shutdown();

final String expectedMessage = "The client has already been shutdown";
expectError(() -> waitFor(reference.get()), expectedMessage);
expectError(() -> waitFor(reference.update("Field", 1)), expectedMessage);
expectError(
() -> waitFor(reference.set(Collections.singletonMap("Field", 1))), expectedMessage);
expectError(
() -> waitFor(instance.runBatch((batch) -> batch.update(reference, "Field", 1))),
expectedMessage);
expectError(
() -> waitFor(instance.runTransaction(transaction -> transaction.get(reference))),
expectedMessage);
}

@Test
public void testShutdownCalledMultipleTimes() {
FirebaseFirestore instance = testFirestore();
DocumentReference reference = instance.document("abc/123");
waitFor(reference.set(Collections.singletonMap("Field", 100)));

instance.shutdown();

final String expectedMessage = "The client has already been shutdown";
expectError(() -> waitFor(reference.get()), expectedMessage);

// Calling a second time should go through and change nothing.
instance.shutdown();

expectError(() -> waitFor(reference.get()), expectedMessage);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.TaskCompletionSource;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.FirebaseApp;
import com.google.firebase.firestore.AccessHelper;
import com.google.firebase.firestore.BuildConfig;
import com.google.firebase.firestore.CollectionReference;
Expand Down Expand Up @@ -145,6 +146,10 @@ protected String checkAuthority(String authority) {
return settings.build();
}

public static FirebaseApp testFirebaseApp() {
return FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext());
}

/** Initializes a new Firestore instance that uses the default project. */
public static FirebaseFirestore testFirestore() {
return testFirestore(newTestSettings());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,13 @@ static FirebaseFirestore newInstance(
// so there is no need to include it in the persistence key.
String persistenceKey = app.getName();

return new FirebaseFirestore(context, databaseId, persistenceKey, provider, queue, app);
FirebaseFirestore firestore =
new FirebaseFirestore(context, databaseId, persistenceKey, provider, queue, app);
app.addLifecycleEventListener(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the wrong layer at which to do this. newInstance shouldn't be registering a listener for this individual instance.

Instead, have FirestoreMultiDbComponent implement FirebaseAppLifecycleListener itself and have it register once, as the final action in its constructor. In the onDeleted implementation, iterate through all instances and shut down the instances.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

(firebaseAppName, options) -> {
firestore.shutdown(/* fromAppDeletion= */ true);
});
return firestore;
}

@VisibleForTesting
Expand Down Expand Up @@ -329,13 +335,42 @@ public Task<Void> runBatch(@NonNull WriteBatch.Function batchFunction) {
return batch.commit();
}

@VisibleForTesting
Task<Void> shutdown() {
Task<Void> shutdown(boolean fromAppDeletion) {
if (!fromAppDeletion && this.getApp() != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be clearer if we split this apart:

  • Public shutdown could just unconditionally navigate to to the component and remove this instance.
  • If App is deleted, the lifecycle implementation in FirestoreMultiDbComponent will take care of dropping from that direction.
  • Have this method be package protected and not take a parameter. Call it something like shutdownInternal. (The precedent would be e.g. disableNetwork and disableNetworkInternal in RemoteStore.
  • Have this method only take the actions outside the if block.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

FirestoreMultiDbComponent component = this.getApp().get(FirestoreMultiDbComponent.class);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should not modify the errorprone check for "convenience" as this convenience comes at a price:

  • You're essentially gaining access to any undeclared component dependency. This is something we(acore) want teams to do only if absolutely necessary and only as a temporary measure that they should consider tech debt.
  • It makes the code harder to unit test as you're no longer getting everything you need via a constructor but effectively gaining access to services through another object you have access to via a "service locator" pattern(see https://github.com/google/guice/wiki/InjectOnlyDirectDependencies)
  • Additionally, in this particular case, you are coupling this class to FirestoreMultiDbComponent imo unnecessarily

That said, I'd recommend something along the following lines:

  • add a constructor parameter to FirebaseFirestore in the form of Consumer<String> onShutdown(or equivalent as it is java8 only)
  • have FirestoreMultiDbComponent inject the Consumer<String> into Firestore as a simple lambda that removes the instance based on the provided String dbId
  • have firestore call onShutdown when appropriate.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically what you suggested, slight tweak.

component.remove(this.databaseId.getDatabaseId());
}
// The client must be initialized to ensure that all subsequent API usage throws an exception.
this.ensureClientConfigured();
return client.shutdown();
}

/**
* Shuts down this FirebaseFirestore instance.
*
* <p>After shutdown only the {@link #clearPersistence()} method may be used. Any other method
* will throw an {@link IllegalStateException}.
*
* <p>To restart after shutdown, simply create a new instance of FirebaseFirestore with {@link
* #getInstance()} or {@link #getInstance(FirebaseApp)}.
*
* <p>Shutdown does not cancel any pending writes and any tasks that are awaiting a response from
* the server will not be resolved. The next time you start this instance, it will resume
* attempting to send these writes to the server.
*
* <p>Note: Under normal circumstances, calling <code>shutdown()</code> is not required. This
* method is useful only when you want to force this instance to release all of its resources or
* in combination with {@link #clearPersistence} to ensure that all local state is destroyed
* between test runs.
*
* @return A <code>Task</code> that is resolved when the instance has been successfully shut down.
*/
@VisibleForTesting
// TODO(b/135755126): Make this public and remove @VisibleForTesting
Task<Void> shutdown() {
return shutdown(/* fromAppDeletion= */ false);
}

@VisibleForTesting
AsyncQueue getAsyncQueue() {
return asyncQueue;
Expand Down Expand Up @@ -396,7 +431,7 @@ public static void setLoggingEnabled(boolean loggingEnabled) {
@PublicApi
public Task<Void> clearPersistence() {
final TaskCompletionSource<Void> source = new TaskCompletionSource<>();
asyncQueue.enqueueAndForget(
asyncQueue.enqueueAndForgetEvenAfterShutdown(
() -> {
try {
if (client != null && !client.isShutdown()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,25 @@ class FirestoreMultiDbComponent {
this.authProvider = authProvider;
}

/** Provides instances of Firestore for given database names. */
/** Provides instances of Firestore for given database IDs. */
@NonNull
synchronized FirebaseFirestore get(@NonNull String databaseName) {
FirebaseFirestore firestore = instances.get(databaseName);
synchronized FirebaseFirestore get(@NonNull String databaseId) {
FirebaseFirestore firestore = instances.get(databaseId);
if (firestore == null) {
firestore = FirebaseFirestore.newInstance(context, app, authProvider, databaseName);
instances.put(databaseName, firestore);
firestore = FirebaseFirestore.newInstance(context, app, authProvider, databaseId);
instances.put(databaseId, firestore);
}
return firestore;
}

/**
* Remove the instance of a given database ID from this component, such that if {@link
* FirestoreMultiDbComponent#get(String)} is called again with the same name, a new instance of
* {@link FirebaseFirestore} is created.
*
* <p>It is a no-op if there is no instance associated with the given database name.
*/
synchronized void remove(@NonNull String databaseId) {
instances.remove(databaseId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ public final class FirestoreClient implements RemoteStore.RemoteStoreCallback {
private RemoteStore remoteStore;
private SyncEngine syncEngine;
private EventManager eventManager;
private volatile boolean clientShutdown = false;

// LRU-related
@Nullable private LruGarbageCollector.Scheduler lruScheduler;
Expand Down Expand Up @@ -138,21 +137,21 @@ public Task<Void> enableNetwork() {
/** Shuts down this client, cancels all writes / listeners, and releases all resources. */
public Task<Void> shutdown() {
credentialsProvider.removeChangeListener();
return asyncQueue.enqueue(
return asyncQueue.enqueueAndInitiateShutdown(
() -> {
if (!this.clientShutdown) {
remoteStore.shutdown();
persistence.shutdown();
if (lruScheduler != null) {
lruScheduler.stop();
}
this.clientShutdown = true;
remoteStore.shutdown();
persistence.shutdown();
if (lruScheduler != null) {
lruScheduler.stop();
}
});
}

/** Returns true if this client has been shutdown. */
public boolean isShutdown() {
return this.clientShutdown;
// Technically, the asyncQueue is still running, but only accepting tasks related to shutdown
// or supposed to be run after shutdown. It is effectively shut down to the eyes of users.
return this.asyncQueue.isShuttingDown();
}

/** Starts listening to a query. */
Expand Down Expand Up @@ -272,8 +271,8 @@ private void initialize(Context context, User user, boolean usePersistence, long
}

private void verifyNotShutdown() {
if (this.clientShutdown) {
throw new IllegalArgumentException("The client has already been shutdown");
if (this.isShutdown()) {
throw new IllegalStateException("The client has already been shutdown");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import io.grpc.ManagedChannelBuilder;
import io.grpc.MethodDescriptor;
import io.grpc.android.AndroidChannelBuilder;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

/** Manages the gRPC channel and encapsulates all SSL and gRPC initialization. */
Expand Down Expand Up @@ -137,48 +138,53 @@ <ReqT, RespT> Task<ClientCall<ReqT, RespT>> createClientCall(

/** Shuts down the gRPC channel and the internal worker queue. */
void shutdown() {
channelTask.addOnCompleteListener(
asyncQueue.getExecutor(),
task -> {
ManagedChannel channel = task.getResult();
channel.shutdown();
try {
// TODO(rsgowman): Investigate occasional hangs in channel.shutdown().
//
// While running the integration tests, channel.shutdown() will occasionally timeout.
// (Typically on ~4-5 different tests, differing from one run to the next.) We should
// figure this out. But in the meantime, just use an exceptionally short timeout here
// and skip straight to shutdownNow() which works every time. (We don't support shutting
// down Firestore, so this should only be triggered from the test suite.)
if (!channel.awaitTermination(1, TimeUnit.SECONDS)) {
Logger.debug(
FirestoreChannel.class.getSimpleName(),
"Unable to gracefully shutdown the gRPC ManagedChannel. Will attempt an immediate shutdown.");
channel.shutdownNow();

// gRPC docs claim "Although forceful, the shutdown process is still not
// instantaneous; isTerminated() will likely return false immediately after this
// method returns." Therefore, we still need to awaitTermination() again.
if (!channel.awaitTermination(60, TimeUnit.SECONDS)) {
// Something bad has happened. We could assert, but this is just resource cleanup
// for a resource that is likely only released at the end of the execution. So
// instead, we'll just log the error.
Logger.warn(
FirestoreChannel.class.getSimpleName(),
"Unable to forcefully shutdown the gRPC ManagedChannel.");
}
}
} catch (InterruptedException e) {
// (Re-)Cancel if current thread also interrupted
channel.shutdownNow();

// Similar to above, something bad happened, but it's not worth asserting. Just log it.
Logger.warn(
FirestoreChannel.class.getSimpleName(),
"Interrupted while shutting down the gRPC Managed Channel");
// Preserve interrupt status
Thread.currentThread().interrupt();
}
});
ManagedChannel channel = null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a comment about why we're handling it synchronously because this is otherwise a suspicious idea.

Something like: "Handle shutdown synchronously to avoid re-enqueuing on the AsyncQueue after shutdown has started."

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

try {
channel = Tasks.await(channelTask);
} catch (ExecutionException | InterruptedException e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you catch InterruptedException you need to re-interrupt the current thread. That means that you can't handle these cases together.

Also, if there was an ExecutionException this may be our only opportunity to log what happened, so we should log the the exception too.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Logger.warn(
FirestoreChannel.class.getSimpleName(),
"Channel is not initialized, shutdown will just do nothing.");
return;
}

channel.shutdown();
try {
// TODO(rsgowman): Investigate occasional hangs in channel.shutdown().
//
// While running the integration tests, channel.shutdown() will occasionally timeout.
// (Typically on ~4-5 different tests, differing from one run to the next.) We should
// figure this out. But in the meantime, just use an exceptionally short timeout here
// and skip straight to shutdownNow() which works every time. (We don't support shutting
// down Firestore, so this should only be triggered from the test suite.)
if (!channel.awaitTermination(1, TimeUnit.SECONDS)) {
Logger.debug(
FirestoreChannel.class.getSimpleName(),
"Unable to gracefully shutdown the gRPC ManagedChannel. Will attempt an immediate shutdown.");
channel.shutdownNow();

// gRPC docs claim "Although forceful, the shutdown process is still not
// instantaneous; isTerminated() will likely return false immediately after this
// method returns." Therefore, we still need to awaitTermination() again.
if (!channel.awaitTermination(60, TimeUnit.SECONDS)) {
// Something bad has happened. We could assert, but this is just resource cleanup
// for a resource that is likely only released at the end of the execution. So
// instead, we'll just log the error.
Logger.warn(
FirestoreChannel.class.getSimpleName(),
"Unable to forcefully shutdown the gRPC ManagedChannel.");
}
}
} catch (InterruptedException e) {
// (Re-)Cancel if current thread also interrupted
channel.shutdownNow();

// Similar to above, something bad happened, but it's not worth asserting. Just log it.
Logger.warn(
FirestoreChannel.class.getSimpleName(),
"Interrupted while shutting down the gRPC Managed Channel");
// Preserve interrupt status
Thread.currentThread().interrupt();
}
}
}
Loading