21
21
import static com .google .firebase .firestore .util .Util .repeatSequence ;
22
22
23
23
import android .database .Cursor ;
24
+ import androidx .annotation .NonNull ;
24
25
import androidx .annotation .VisibleForTesting ;
25
26
import com .google .firebase .Timestamp ;
26
27
import com .google .firebase .database .collection .ImmutableSortedMap ;
40
41
import java .util .Collection ;
41
42
import java .util .Collections ;
42
43
import java .util .HashMap ;
44
+ import java .util .Iterator ;
43
45
import java .util .List ;
44
46
import java .util .Map ;
47
+ import java .util .Objects ;
45
48
import java .util .Set ;
49
+ import java .util .concurrent .ConcurrentHashMap ;
46
50
import java .util .concurrent .Executor ;
47
51
import javax .annotation .Nonnull ;
48
52
import javax .annotation .Nullable ;
@@ -55,6 +59,8 @@ final class SQLiteRemoteDocumentCache implements RemoteDocumentCache {
55
59
private final LocalSerializer serializer ;
56
60
private IndexManager indexManager ;
57
61
62
+ private final DocumentTypeBackfiller documentTypeBackfiller = new DocumentTypeBackfiller ();
63
+
58
64
SQLiteRemoteDocumentCache (SQLitePersistence persistence , LocalSerializer serializer ) {
59
65
this .db = persistence ;
60
66
this .serializer = serializer ;
@@ -65,6 +71,32 @@ public void setIndexManager(IndexManager indexManager) {
65
71
this .indexManager = indexManager ;
66
72
}
67
73
74
+ private enum DocumentType {
75
+ NO_DOCUMENT (1 ),
76
+ FOUND_DOCUMENT (2 ),
77
+ UNKNOWN_DOCUMENT (3 ),
78
+ INVALID_DOCUMENT (4 );
79
+
80
+ final int dbValue ;
81
+
82
+ DocumentType (int dbValue ) {
83
+ this .dbValue = dbValue ;
84
+ }
85
+
86
+ static DocumentType forMutableDocument (MutableDocument document ) {
87
+ if (document .isNoDocument ()) {
88
+ return NO_DOCUMENT ;
89
+ } else if (document .isFoundDocument ()) {
90
+ return FOUND_DOCUMENT ;
91
+ } else if (document .isUnknownDocument ()) {
92
+ return UNKNOWN_DOCUMENT ;
93
+ } else {
94
+ hardAssert (!document .isValidDocument (), "MutableDocument has an unknown type" );
95
+ return INVALID_DOCUMENT ;
96
+ }
97
+ }
98
+ }
99
+
68
100
@ Override
69
101
public void add (MutableDocument document , SnapshotVersion readTime ) {
70
102
hardAssert (
@@ -77,12 +109,13 @@ public void add(MutableDocument document, SnapshotVersion readTime) {
77
109
78
110
db .execute (
79
111
"INSERT OR REPLACE INTO remote_documents "
80
- + "(path, path_length, read_time_seconds, read_time_nanos, contents) "
81
- + "VALUES (?, ?, ?, ?, ?)" ,
112
+ + "(path, path_length, read_time_seconds, read_time_nanos, document_type, contents) "
113
+ + "VALUES (?, ?, ?, ?, ?, ? )" ,
82
114
EncodedPath .encode (documentKey .getPath ()),
83
115
documentKey .getPath ().length (),
84
116
timestamp .getSeconds (),
85
117
timestamp .getNanoseconds (),
118
+ DocumentType .forMutableDocument (document ).dbValue ,
86
119
message .toByteArray ());
87
120
88
121
indexManager .addToCollectionParentIndex (document .getKey ().getCollectionPath ());
@@ -131,7 +164,8 @@ public Map<DocumentKey, MutableDocument> getAll(Iterable<DocumentKey> documentKe
131
164
SQLitePersistence .LongQuery longQuery =
132
165
new SQLitePersistence .LongQuery (
133
166
db ,
134
- "SELECT contents, read_time_seconds, read_time_nanos FROM remote_documents "
167
+ "SELECT contents, read_time_seconds, read_time_nanos, document_type, path "
168
+ + "FROM remote_documents "
135
169
+ "WHERE path IN (" ,
136
170
bindVars ,
137
171
") ORDER BY path" );
@@ -143,7 +177,14 @@ public Map<DocumentKey, MutableDocument> getAll(Iterable<DocumentKey> documentKe
143
177
.forEach (row -> processRowInBackground (backgroundQueue , results , row , /*filter*/ null ));
144
178
}
145
179
backgroundQueue .drain ();
146
- return results ;
180
+
181
+ // Backfill any rows with null "document_type" discovered by processRowInBackground().
182
+ documentTypeBackfiller .backfill (db );
183
+
184
+ // Synchronize on `results` to avoid a data race with the background queue.
185
+ synchronized (results ) {
186
+ return results ;
187
+ }
147
188
}
148
189
149
190
@ Override
@@ -182,30 +223,40 @@ private Map<DocumentKey, MutableDocument> getAll(
182
223
List <ResourcePath > collections ,
183
224
IndexOffset offset ,
184
225
int count ,
226
+ @ Nullable DocumentType tryFilterDocumentType ,
185
227
@ Nullable Function <MutableDocument , Boolean > filter ,
186
228
@ Nullable QueryContext context ) {
187
229
Timestamp readTime = offset .getReadTime ().getTimestamp ();
188
230
DocumentKey documentKey = offset .getDocumentKey ();
189
231
190
232
StringBuilder sql =
191
233
repeatSequence (
192
- "SELECT contents, read_time_seconds, read_time_nanos, path "
234
+ "SELECT contents, read_time_seconds, read_time_nanos, document_type, path "
193
235
+ "FROM remote_documents "
194
236
+ "WHERE path >= ? AND path < ? AND path_length = ? "
237
+ + (tryFilterDocumentType == null
238
+ ? ""
239
+ : " AND (document_type IS NULL OR document_type = ?) " )
195
240
+ "AND (read_time_seconds > ? OR ( "
196
241
+ "read_time_seconds = ? AND read_time_nanos > ?) OR ( "
197
242
+ "read_time_seconds = ? AND read_time_nanos = ? and path > ?)) " ,
198
243
collections .size (),
199
244
" UNION " );
200
245
sql .append ("ORDER BY read_time_seconds, read_time_nanos, path LIMIT ?" );
201
246
202
- Object [] bindVars = new Object [BINDS_PER_STATEMENT * collections .size () + 1 ];
247
+ Object [] bindVars =
248
+ new Object
249
+ [(BINDS_PER_STATEMENT + (tryFilterDocumentType != null ? 1 : 0 )) * collections .size ()
250
+ + 1 ];
203
251
int i = 0 ;
204
252
for (ResourcePath collection : collections ) {
205
253
String prefixPath = EncodedPath .encode (collection );
206
254
bindVars [i ++] = prefixPath ;
207
255
bindVars [i ++] = EncodedPath .prefixSuccessor (prefixPath );
208
256
bindVars [i ++] = collection .length () + 1 ;
257
+ if (tryFilterDocumentType != null ) {
258
+ bindVars [i ++] = tryFilterDocumentType .dbValue ;
259
+ }
209
260
bindVars [i ++] = readTime .getSeconds ();
210
261
bindVars [i ++] = readTime .getSeconds ();
211
262
bindVars [i ++] = readTime .getNanoseconds ();
@@ -227,15 +278,23 @@ private Map<DocumentKey, MutableDocument> getAll(
227
278
}
228
279
});
229
280
backgroundQueue .drain ();
230
- return results ;
281
+
282
+ // Backfill any null "document_type" columns discovered by processRowInBackground().
283
+ documentTypeBackfiller .backfill (db );
284
+
285
+ // Synchronize on `results` to avoid a data race with the background queue.
286
+ synchronized (results ) {
287
+ return results ;
288
+ }
231
289
}
232
290
233
291
private Map <DocumentKey , MutableDocument > getAll (
234
292
List <ResourcePath > collections ,
235
293
IndexOffset offset ,
236
294
int count ,
237
295
@ Nullable Function <MutableDocument , Boolean > filter ) {
238
- return getAll (collections , offset , count , filter , /*context*/ null );
296
+ return getAll (
297
+ collections , offset , count , /*tryFilterDocumentType*/ null , filter , /*context*/ null );
239
298
}
240
299
241
300
private void processRowInBackground (
@@ -246,6 +305,8 @@ private void processRowInBackground(
246
305
byte [] rawDocument = row .getBlob (0 );
247
306
int readTimeSeconds = row .getInt (1 );
248
307
int readTimeNanos = row .getInt (2 );
308
+ boolean documentTypeIsNull = row .isNull (3 );
309
+ String path = row .getString (4 );
249
310
250
311
// Since scheduling background tasks incurs overhead, we only dispatch to a
251
312
// background thread if there are still some documents remaining.
@@ -254,6 +315,9 @@ private void processRowInBackground(
254
315
() -> {
255
316
MutableDocument document =
256
317
decodeMaybeDocument (rawDocument , readTimeSeconds , readTimeNanos );
318
+ if (documentTypeIsNull ) {
319
+ documentTypeBackfiller .enqueue (path , readTimeSeconds , readTimeNanos , document );
320
+ }
257
321
if (filter == null || filter .apply (document )) {
258
322
synchronized (results ) {
259
323
results .put (document .getKey (), document );
@@ -278,6 +342,10 @@ public Map<DocumentKey, MutableDocument> getDocumentsMatchingQuery(
278
342
Collections .singletonList (query .getPath ()),
279
343
offset ,
280
344
Integer .MAX_VALUE ,
345
+ // Specify tryFilterDocumentType=FOUND_DOCUMENT to getAll() as an optimization, because
346
+ // query.matches(doc) will return false for all non-"found" document types anyways.
347
+ // See https://github.com/firebase/firebase-android-sdk/issues/7295
348
+ DocumentType .FOUND_DOCUMENT ,
281
349
(MutableDocument doc ) -> query .matches (doc ) || mutatedKeys .contains (doc .getKey ()),
282
350
context );
283
351
}
@@ -292,4 +360,165 @@ private MutableDocument decodeMaybeDocument(
292
360
throw fail ("MaybeDocument failed to parse: %s" , e );
293
361
}
294
362
}
363
+
364
+ /**
365
+ * Helper class to backfill the `document_type` column in the `remote_documents` table.
366
+ * <p>
367
+ * The `document_type` column was added as an optimization to skip deleted document tombstones
368
+ * when running queries. Any time a new row is added to the `remote_documents` table it _should_
369
+ * have its `document_type` column set to the value that matches the `contents` field. However,
370
+ * when upgrading from an older schema version the column value for existing rows will be null
371
+ * and this backfiller is intended to replace those null values to improve the future performance
372
+ * of queries.
373
+ * <p>
374
+ * When traversing the `remote_documents` table call `add()` upon finding a row whose
375
+ * `document_type` is null. Then, call `backfill()` later on to efficiently update the added
376
+ * rows in batches.
377
+ * <p>
378
+ * This class is thread safe and all public methods may be safely called concurrently from
379
+ * multiple threads. This makes it safe to use instances of this class from BackgroundQueue.
380
+ *
381
+ * @see <a href="https://github.com/firebase/firebase-android-sdk/issues/7295">#7295</a>
382
+ */
383
+ private static class DocumentTypeBackfiller {
384
+
385
+ private final ConcurrentHashMap <BackfillKey , DocumentType > documentTypeByBackfillKey =
386
+ new ConcurrentHashMap <>();
387
+
388
+ void enqueue (String path , int readTimeSeconds , int readTimeNanos , MutableDocument document ) {
389
+ BackfillKey backfillKey = new BackfillKey (path , readTimeSeconds , readTimeNanos );
390
+ DocumentType documentType = DocumentType .forMutableDocument (document );
391
+ documentTypeByBackfillKey .putIfAbsent (backfillKey , documentType );
392
+ }
393
+
394
+ void backfill (SQLitePersistence db ) {
395
+ while (true ) {
396
+ BackfillSqlInfo backfillSqlInfo = calculateBackfillSql ();
397
+ if (backfillSqlInfo == null ) {
398
+ break ;
399
+ }
400
+ db .execute (backfillSqlInfo .sql , backfillSqlInfo .bindings );
401
+ }
402
+ }
403
+
404
+ private static class BackfillSqlInfo {
405
+ final String sql ;
406
+ final Object [] bindings ;
407
+ final int numDocumentsAffected ;
408
+
409
+ BackfillSqlInfo (String sql , Object [] bindings , int numDocumentsAffected ) {
410
+ this .sql = sql ;
411
+ this .bindings = bindings ;
412
+ this .numDocumentsAffected = numDocumentsAffected ;
413
+ }
414
+ }
415
+
416
+ @ Nullable
417
+ BackfillSqlInfo calculateBackfillSql () {
418
+ if (documentTypeByBackfillKey .isEmpty ()) {
419
+ return null ; // short circuit
420
+ }
421
+
422
+ ArrayList <Object > bindings = new ArrayList <>();
423
+ StringBuilder caseClauses = new StringBuilder ();
424
+ StringBuilder whereClauses = new StringBuilder ();
425
+
426
+ Iterator <BackfillKey > backfillKeys = documentTypeByBackfillKey .keySet ().iterator ();
427
+ int numDocumentsAffected = 0 ;
428
+ while (backfillKeys .hasNext () && bindings .size () < SQLitePersistence .LongQuery .LIMIT ) {
429
+ BackfillKey backfillKey = backfillKeys .next ();
430
+ DocumentType documentType = documentTypeByBackfillKey .remove (backfillKey );
431
+ if (documentType == null ) {
432
+ continue ;
433
+ }
434
+
435
+ numDocumentsAffected ++;
436
+ bindings .add (backfillKey .path );
437
+ int pathBindingNumber = bindings .size ();
438
+ bindings .add (backfillKey .readTimeSeconds );
439
+ int readTimeSecondsBindingNumber = bindings .size ();
440
+ bindings .add (backfillKey .readTimeNanos );
441
+ int readTimeNanosBindingNumber = bindings .size ();
442
+ bindings .add (documentType .dbValue );
443
+ int dbValueBindingNumber = bindings .size ();
444
+
445
+ caseClauses
446
+ .append (" WHEN path=?" )
447
+ .append (pathBindingNumber )
448
+ .append (" AND read_time_seconds=?" )
449
+ .append (readTimeSecondsBindingNumber )
450
+ .append (" AND read_time_nanos=?" )
451
+ .append (readTimeNanosBindingNumber )
452
+ .append (" THEN ?" )
453
+ .append (dbValueBindingNumber );
454
+
455
+ if (whereClauses .length () > 0 ) {
456
+ whereClauses .append (" OR" );
457
+ }
458
+ whereClauses
459
+ .append (" (path=?" )
460
+ .append (pathBindingNumber )
461
+ .append (" AND read_time_seconds=?" )
462
+ .append (readTimeSecondsBindingNumber )
463
+ .append (" AND read_time_nanos=?" )
464
+ .append (readTimeNanosBindingNumber )
465
+ .append (')' );
466
+ }
467
+
468
+ if (numDocumentsAffected == 0 ) {
469
+ return null ;
470
+ }
471
+
472
+ String sql =
473
+ "UPDATE remote_documents SET document_type = CASE"
474
+ + caseClauses
475
+ + " ELSE NULL END WHERE"
476
+ + whereClauses ;
477
+
478
+ return new BackfillSqlInfo (sql , bindings .toArray (), numDocumentsAffected );
479
+ }
480
+
481
+ private static class BackfillKey {
482
+ final String path ;
483
+ final int readTimeSeconds ;
484
+ final int readTimeNanos ;
485
+
486
+ BackfillKey (String path , int readTimeSeconds , int readTimeNanos ) {
487
+ this .path = path ;
488
+ this .readTimeSeconds = readTimeSeconds ;
489
+ this .readTimeNanos = readTimeNanos ;
490
+ }
491
+
492
+ @ Override
493
+ public boolean equals (Object object ) {
494
+ if (object == this ) {
495
+ return true ;
496
+ }
497
+ if (!(object instanceof BackfillKey )) {
498
+ return false ;
499
+ }
500
+ BackfillKey other = (BackfillKey ) object ;
501
+ return readTimeSeconds == other .readTimeSeconds
502
+ && readTimeNanos == other .readTimeNanos
503
+ && Objects .equals (path , other .path );
504
+ }
505
+
506
+ @ Override
507
+ public int hashCode () {
508
+ return Objects .hash (path , readTimeSeconds , readTimeNanos );
509
+ }
510
+
511
+ @ NonNull
512
+ @ Override
513
+ public String toString () {
514
+ return "DocumentTypeBackfiller.BackfillKey(path="
515
+ + path
516
+ + ", readTimeSeconds="
517
+ + readTimeSeconds
518
+ + ", readTimeNanos="
519
+ + readTimeNanos
520
+ + ")" ;
521
+ }
522
+ }
523
+ }
295
524
}
0 commit comments