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 3 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,58 @@ 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();
final DocumentReference reference = instance.document("abc/123");
DocumentReference ref = reference;
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);
}
}
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.

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

@VisibleForTesting
Expand Down Expand Up @@ -329,13 +335,43 @@ 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 Firestore SDK instance.
*
* <p>IMPORTANT: If your application is designed to run with Firestore the entire life cycle, it
* is not recommended to call `shutdown` explicitly, the shutting down happens naturally when the
* application itself is shut down.
*
* <p>For applications designed to run without Firestore from some point on (typically to save
* resources as much as possible), you may call this method.
*
* <p>Once `shutdown` is called, trying to issue new reads/writes through this instance will
* result in {@link IllegalStateException}. Already requested reads/writes might stop resolving.
* Be sure to handle this well in your application if proceeding with Firestore shutting down is
* desired in your case.
*
* <p>Note that {@link #clearPersistence()} is an exception, it is designed to run when the
* Firestore SDK instance is shutdown.
*
* <p>To re-start a Firestore SDK instance, simply create a new instance via {@link
* #getInstance()} or {@link #getInstance(FirebaseApp)}.
*/
@VisibleForTesting
// TODO: Make this public and remove @VisibleForTesting
Task<Void> shutdown() {
return shutdown(/* fromAppDeletion */ false);
}

@VisibleForTesting
AsyncQueue getAsyncQueue() {
return asyncQueue;
Expand Down Expand Up @@ -396,7 +432,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 @@ -45,12 +45,23 @@ class FirestoreMultiDbComponent {

/** Provides instances of Firestore for given database names. */
@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 name 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();
}
});
}

/** Has this client been shutdown. */
Copy link
Contributor

Choose a reason for hiding this comment

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

Use a verb phrase that describes what the method does as if the sentence starts with "This method" (see http://go/java-practices/javadoc#summary_methods).

e.g.

/** Returns true if this client has been shut down. */

Copy link
Author

Choose a reason for hiding this comment

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

Done.

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 @@ -138,7 +138,7 @@ <ReqT, RespT> Task<ClientCall<ReqT, RespT>> createClientCall(
/** Shuts down the gRPC channel and the internal worker queue. */
void shutdown() {
channelTask.addOnCompleteListener(
asyncQueue.getExecutor(),
asyncQueue.getExecutorForShutdown(),
task -> {
ManagedChannel channel = task.getResult();
channel.shutdown();
Expand Down
Loading