Skip to content

Commit 8152ab8

Browse files
author
Hui-Wu
authored
Prepare shutdown to be a public api. (#589)
* Deregister firestore when shutdown + app.delete calls shutdown * synchronized scheduling + soft shutdown * Add comments and more tests * addressing comments #1 * fixing format in tools * Stop leakages * better comments * Handle app delete shutdown differently * No more error-prone hack * Change how to interact with MultiDbComponent. * add missing sync keyword
1 parent b36d6c0 commit 8152ab8

File tree

9 files changed

+468
-150
lines changed

9 files changed

+468
-150
lines changed

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/AccessHelper.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,16 @@ public static FirebaseFirestore newFirebaseFirestore(
3131
String persistenceKey,
3232
CredentialsProvider credentialsProvider,
3333
AsyncQueue asyncQueue,
34-
FirebaseApp firebaseApp) {
34+
FirebaseApp firebaseApp,
35+
FirebaseFirestore.InstanceRegistry instanceRegistry) {
3536
return new FirebaseFirestore(
36-
context, databaseId, persistenceKey, credentialsProvider, asyncQueue, firebaseApp);
37+
context,
38+
databaseId,
39+
persistenceKey,
40+
credentialsProvider,
41+
asyncQueue,
42+
firebaseApp,
43+
instanceRegistry);
3744
}
3845

3946
/** Makes the shutdown method accessible. */

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollection;
2121
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollectionWithDocs;
2222
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testDocument;
23+
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirebaseApp;
2324
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirestore;
2425
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor;
2526
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitForException;
@@ -30,6 +31,7 @@
3031
import static org.junit.Assert.assertFalse;
3132
import static org.junit.Assert.assertNotEquals;
3233
import static org.junit.Assert.assertNotNull;
34+
import static org.junit.Assert.assertNotSame;
3335
import static org.junit.Assert.assertNull;
3436
import static org.junit.Assert.assertSame;
3537
import static org.junit.Assert.assertTrue;
@@ -38,6 +40,7 @@
3840
import androidx.test.ext.junit.runners.AndroidJUnit4;
3941
import com.google.android.gms.tasks.Task;
4042
import com.google.android.gms.tasks.TaskCompletionSource;
43+
import com.google.firebase.FirebaseApp;
4144
import com.google.firebase.Timestamp;
4245
import com.google.firebase.firestore.FirebaseFirestoreException.Code;
4346
import com.google.firebase.firestore.Query.Direction;
@@ -1023,4 +1026,74 @@ public void testClearPersistenceWhileRunningFails() {
10231026
FirebaseFirestoreException firestoreException = (FirebaseFirestoreException) e;
10241027
assertEquals(Code.FAILED_PRECONDITION, firestoreException.getCode());
10251028
}
1029+
1030+
@Test
1031+
public void testRestartFirestoreLeadsToNewInstance() {
1032+
FirebaseApp app = testFirebaseApp();
1033+
FirebaseFirestore instance = FirebaseFirestore.getInstance(app);
1034+
FirebaseFirestore sameInstance = FirebaseFirestore.getInstance(app);
1035+
1036+
assertSame(instance, sameInstance);
1037+
waitFor(instance.document("abc/123").set(Collections.singletonMap("field", 100L)));
1038+
1039+
instance.shutdown();
1040+
FirebaseFirestore newInstance = FirebaseFirestore.getInstance(app);
1041+
1042+
// Verify new instance works.
1043+
DocumentSnapshot doc = waitFor(newInstance.document("abc/123").get());
1044+
assertEquals(doc.get("field"), 100L);
1045+
waitFor(newInstance.document("abc/123").delete());
1046+
1047+
// Verify it is different instance.
1048+
assertNotSame(instance, newInstance);
1049+
}
1050+
1051+
@Test
1052+
public void testAppDeleteLeadsToFirestoreShutdown() {
1053+
FirebaseApp app = testFirebaseApp();
1054+
FirebaseFirestore instance = FirebaseFirestore.getInstance(app);
1055+
waitFor(instance.document("abc/123").set(Collections.singletonMap("Field", 100)));
1056+
1057+
app.delete();
1058+
1059+
assertTrue(instance.getClient().isShutdown());
1060+
}
1061+
1062+
@Test
1063+
public void testNewOperationThrowsAfterFirestoreShutdown() {
1064+
FirebaseFirestore instance = testFirestore();
1065+
DocumentReference reference = instance.document("abc/123");
1066+
waitFor(reference.set(Collections.singletonMap("Field", 100)));
1067+
1068+
instance.shutdown();
1069+
1070+
final String expectedMessage = "The client has already been shutdown";
1071+
expectError(() -> waitFor(reference.get()), expectedMessage);
1072+
expectError(() -> waitFor(reference.update("Field", 1)), expectedMessage);
1073+
expectError(
1074+
() -> waitFor(reference.set(Collections.singletonMap("Field", 1))), expectedMessage);
1075+
expectError(
1076+
() -> waitFor(instance.runBatch((batch) -> batch.update(reference, "Field", 1))),
1077+
expectedMessage);
1078+
expectError(
1079+
() -> waitFor(instance.runTransaction(transaction -> transaction.get(reference))),
1080+
expectedMessage);
1081+
}
1082+
1083+
@Test
1084+
public void testShutdownCalledMultipleTimes() {
1085+
FirebaseFirestore instance = testFirestore();
1086+
DocumentReference reference = instance.document("abc/123");
1087+
waitFor(reference.set(Collections.singletonMap("Field", 100)));
1088+
1089+
instance.shutdown();
1090+
1091+
final String expectedMessage = "The client has already been shutdown";
1092+
expectError(() -> waitFor(reference.get()), expectedMessage);
1093+
1094+
// Calling a second time should go through and change nothing.
1095+
instance.shutdown();
1096+
1097+
expectError(() -> waitFor(reference.get()), expectedMessage);
1098+
}
10261099
}

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.google.android.gms.tasks.Task;
2626
import com.google.android.gms.tasks.TaskCompletionSource;
2727
import com.google.android.gms.tasks.Tasks;
28+
import com.google.firebase.FirebaseApp;
2829
import com.google.firebase.firestore.AccessHelper;
2930
import com.google.firebase.firestore.BuildConfig;
3031
import com.google.firebase.firestore.CollectionReference;
@@ -145,6 +146,10 @@ protected String checkAuthority(String authority) {
145146
return settings.build();
146147
}
147148

149+
public static FirebaseApp testFirebaseApp() {
150+
return FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext());
151+
}
152+
148153
/** Initializes a new Firestore instance that uses the default project. */
149154
public static FirebaseFirestore testFirestore() {
150155
return testFirestore(newTestSettings());
@@ -257,7 +262,8 @@ public static FirebaseFirestore testFirestore(
257262
persistenceKey,
258263
new EmptyCredentialsProvider(),
259264
asyncQueue,
260-
/*firebaseApp=*/ null);
265+
/*firebaseApp=*/ null,
266+
/*instanceRegistry=*/ (dbId) -> {});
261267
waitFor(AccessHelper.clearPersistence(firestore));
262268
firestore.setFirestoreSettings(settings);
263269
firestoreStatus.put(firestore, true);

firebase-firestore/src/main/java/com/google/firebase/firestore/FirebaseFirestore.java

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@
5151
@PublicApi
5252
public class FirebaseFirestore {
5353

54+
/** Provides a registry management interface for {@code FirebaseFirestore} instances. */
55+
public interface InstanceRegistry {
56+
/** Removes the Firestore instance with given name from registry. */
57+
void remove(@NonNull String databaseId);
58+
}
59+
5460
private static final String TAG = "FirebaseFirestore";
5561
private final Context context;
5662
// This is also used as private lock object for this instance. There is nothing inherent about
@@ -61,6 +67,9 @@ public class FirebaseFirestore {
6167
private final AsyncQueue asyncQueue;
6268
private final FirebaseApp firebaseApp;
6369
private final UserDataConverter dataConverter;
70+
// When user requests to shutdown, use this to notify `FirestoreMultiDbComponent` to deregister
71+
// this instance.
72+
private final InstanceRegistry instanceRegistry;
6473
private FirebaseFirestoreSettings settings;
6574
private volatile FirestoreClient client;
6675

@@ -94,7 +103,8 @@ static FirebaseFirestore newInstance(
94103
@NonNull Context context,
95104
@NonNull FirebaseApp app,
96105
@Nullable InternalAuthProvider authProvider,
97-
@NonNull String database) {
106+
@NonNull String database,
107+
@NonNull InstanceRegistry instanceRegistry) {
98108
String projectId = app.getOptions().getProjectId();
99109
if (projectId == null) {
100110
throw new IllegalArgumentException("FirebaseOptions.getProjectId() cannot be null");
@@ -117,7 +127,10 @@ static FirebaseFirestore newInstance(
117127
// so there is no need to include it in the persistence key.
118128
String persistenceKey = app.getName();
119129

120-
return new FirebaseFirestore(context, databaseId, persistenceKey, provider, queue, app);
130+
FirebaseFirestore firestore =
131+
new FirebaseFirestore(
132+
context, databaseId, persistenceKey, provider, queue, app, instanceRegistry);
133+
return firestore;
121134
}
122135

123136
@VisibleForTesting
@@ -127,7 +140,8 @@ static FirebaseFirestore newInstance(
127140
String persistenceKey,
128141
CredentialsProvider credentialsProvider,
129142
AsyncQueue asyncQueue,
130-
@Nullable FirebaseApp firebaseApp) {
143+
@Nullable FirebaseApp firebaseApp,
144+
InstanceRegistry instanceRegistry) {
131145
this.context = checkNotNull(context);
132146
this.databaseId = checkNotNull(checkNotNull(databaseId));
133147
this.dataConverter = new UserDataConverter(databaseId);
@@ -136,6 +150,7 @@ static FirebaseFirestore newInstance(
136150
this.asyncQueue = checkNotNull(asyncQueue);
137151
// NOTE: We allow firebaseApp to be null in tests only.
138152
this.firebaseApp = firebaseApp;
153+
this.instanceRegistry = instanceRegistry;
139154

140155
settings = new FirebaseFirestoreSettings.Builder().build();
141156
}
@@ -329,13 +344,39 @@ public Task<Void> runBatch(@NonNull WriteBatch.Function batchFunction) {
329344
return batch.commit();
330345
}
331346

332-
@VisibleForTesting
333-
Task<Void> shutdown() {
347+
Task<Void> shutdownInternal() {
334348
// The client must be initialized to ensure that all subsequent API usage throws an exception.
335349
this.ensureClientConfigured();
336350
return client.shutdown();
337351
}
338352

353+
/**
354+
* Shuts down this FirebaseFirestore instance.
355+
*
356+
* <p>After shutdown only the {@link #clearPersistence()} method may be used. Any other method
357+
* will throw an {@link IllegalStateException}.
358+
*
359+
* <p>To restart after shutdown, simply create a new instance of FirebaseFirestore with {@link
360+
* #getInstance()} or {@link #getInstance(FirebaseApp)}.
361+
*
362+
* <p>Shutdown does not cancel any pending writes and any tasks that are awaiting a response from
363+
* the server will not be resolved. The next time you start this instance, it will resume
364+
* attempting to send these writes to the server.
365+
*
366+
* <p>Note: Under normal circumstances, calling <code>shutdown()</code> is not required. This
367+
* method is useful only when you want to force this instance to release all of its resources or
368+
* in combination with {@link #clearPersistence} to ensure that all local state is destroyed
369+
* between test runs.
370+
*
371+
* @return A <code>Task</code> that is resolved when the instance has been successfully shut down.
372+
*/
373+
@VisibleForTesting
374+
// TODO(b/135755126): Make this public and remove @VisibleForTesting
375+
Task<Void> shutdown() {
376+
instanceRegistry.remove(this.getDatabaseId().getDatabaseId());
377+
return shutdownInternal();
378+
}
379+
339380
@VisibleForTesting
340381
AsyncQueue getAsyncQueue() {
341382
return asyncQueue;
@@ -396,7 +437,7 @@ public static void setLoggingEnabled(boolean loggingEnabled) {
396437
@PublicApi
397438
public Task<Void> clearPersistence() {
398439
final TaskCompletionSource<Void> source = new TaskCompletionSource<>();
399-
asyncQueue.enqueueAndForget(
440+
asyncQueue.enqueueAndForgetEvenAfterShutdown(
400441
() -> {
401442
try {
402443
if (client != null && !client.isShutdown()) {

firebase-firestore/src/main/java/com/google/firebase/firestore/FirestoreMultiDbComponent.java

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@
1818
import androidx.annotation.NonNull;
1919
import androidx.annotation.Nullable;
2020
import com.google.firebase.FirebaseApp;
21+
import com.google.firebase.FirebaseAppLifecycleListener;
22+
import com.google.firebase.FirebaseOptions;
2123
import com.google.firebase.auth.internal.InternalAuthProvider;
2224
import java.util.HashMap;
2325
import java.util.Map;
2426

2527
/** Multi-resource container for Firestore. */
26-
class FirestoreMultiDbComponent {
28+
class FirestoreMultiDbComponent
29+
implements FirebaseAppLifecycleListener, FirebaseFirestore.InstanceRegistry {
30+
2731
/**
2832
* A static map from instance key to FirebaseFirestore instances. Instance keys are database
2933
* names.
@@ -41,16 +45,38 @@ class FirestoreMultiDbComponent {
4145
this.context = context;
4246
this.app = app;
4347
this.authProvider = authProvider;
48+
this.app.addLifecycleEventListener(this);
4449
}
4550

46-
/** Provides instances of Firestore for given database names. */
51+
/** Provides instances of Firestore for given database IDs. */
4752
@NonNull
48-
synchronized FirebaseFirestore get(@NonNull String databaseName) {
49-
FirebaseFirestore firestore = instances.get(databaseName);
53+
synchronized FirebaseFirestore get(@NonNull String databaseId) {
54+
FirebaseFirestore firestore = instances.get(databaseId);
5055
if (firestore == null) {
51-
firestore = FirebaseFirestore.newInstance(context, app, authProvider, databaseName);
52-
instances.put(databaseName, firestore);
56+
firestore = FirebaseFirestore.newInstance(context, app, authProvider, databaseId, this);
57+
instances.put(databaseId, firestore);
5358
}
5459
return firestore;
5560
}
61+
62+
/**
63+
* Remove the instance of a given database ID from this component, such that if {@link
64+
* FirestoreMultiDbComponent#get(String)} is called again with the same name, a new instance of
65+
* {@link FirebaseFirestore} is created.
66+
*
67+
* <p>It is a no-op if there is no instance associated with the given database name.
68+
*/
69+
@Override
70+
public synchronized void remove(@NonNull String databaseId) {
71+
instances.remove(databaseId);
72+
}
73+
74+
@Override
75+
public synchronized void onDeleted(String firebaseAppName, FirebaseOptions options) {
76+
// Shuts down all database instances and remove them from registry map when App is deleted.
77+
for (Map.Entry<String, FirebaseFirestore> entry : instances.entrySet()) {
78+
entry.getValue().shutdownInternal();
79+
instances.remove(entry.getKey());
80+
}
81+
}
5682
}

firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ public final class FirestoreClient implements RemoteStore.RemoteStoreCallback {
7575
private RemoteStore remoteStore;
7676
private SyncEngine syncEngine;
7777
private EventManager eventManager;
78-
private volatile boolean clientShutdown = false;
7978

8079
// LRU-related
8180
@Nullable private LruGarbageCollector.Scheduler lruScheduler;
@@ -138,21 +137,21 @@ public Task<Void> enableNetwork() {
138137
/** Shuts down this client, cancels all writes / listeners, and releases all resources. */
139138
public Task<Void> shutdown() {
140139
credentialsProvider.removeChangeListener();
141-
return asyncQueue.enqueue(
140+
return asyncQueue.enqueueAndInitiateShutdown(
142141
() -> {
143-
if (!this.clientShutdown) {
144-
remoteStore.shutdown();
145-
persistence.shutdown();
146-
if (lruScheduler != null) {
147-
lruScheduler.stop();
148-
}
149-
this.clientShutdown = true;
142+
remoteStore.shutdown();
143+
persistence.shutdown();
144+
if (lruScheduler != null) {
145+
lruScheduler.stop();
150146
}
151147
});
152148
}
153149

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

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

274273
private void verifyNotShutdown() {
275-
if (this.clientShutdown) {
276-
throw new IllegalArgumentException("The client has already been shutdown");
274+
if (this.isShutdown()) {
275+
throw new IllegalStateException("The client has already been shutdown");
277276
}
278277
}
279278

0 commit comments

Comments
 (0)