1 /*
2  * Copyright 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.room.integration.testapp.test;
18 
19 import static org.hamcrest.CoreMatchers.is;
20 import static org.hamcrest.MatcherAssert.assertThat;
21 
22 import androidx.room.Dao;
23 import androidx.room.Database;
24 import androidx.room.Entity;
25 import androidx.room.Insert;
26 import androidx.room.InvalidationTracker;
27 import androidx.room.PrimaryKey;
28 import androidx.room.Room;
29 import androidx.room.RoomDatabase;
30 import androidx.test.core.app.ApplicationProvider;
31 import androidx.test.ext.junit.runners.AndroidJUnit4;
32 import androidx.test.filters.FlakyTest;
33 import androidx.test.filters.LargeTest;
34 
35 import org.jspecify.annotations.NonNull;
36 import org.jspecify.annotations.Nullable;
37 import org.junit.After;
38 import org.junit.Before;
39 import org.junit.Test;
40 import org.junit.runner.RunWith;
41 
42 import java.util.Set;
43 import java.util.concurrent.CountDownLatch;
44 import java.util.concurrent.ExecutionException;
45 import java.util.concurrent.ExecutorService;
46 import java.util.concurrent.Executors;
47 import java.util.concurrent.TimeUnit;
48 import java.util.concurrent.atomic.AtomicInteger;
49 
50 /**
51  * Regression test for a situation where an InvalidationTracker callback may intermittently be
52  * invoked too early, too late, or not at all, due to missing transactionality in tracking table
53  * code, when distinct database updates occur in close temporal proximity.
54  */
55 @LargeTest
56 @FlakyTest(
57         bugId = 154040286,
58         detail = "Behavioral test for potentially intermittent InvalidationTracker problems"
59 )
60 @RunWith(AndroidJUnit4.class)
61 public class InvalidationTrackerBehavioralTest {
62     private ExecutorService mExecutorService;
63 
64     @Before
setup()65     public void setup() {
66         mExecutorService = Executors.newSingleThreadExecutor();
67     }
68 
69     @After
tearDown()70     public void tearDown() {
71         mExecutorService.shutdown();
72     }
73 
74     @Test
testInserts_JournalModeTruncate()75     public void testInserts_JournalModeTruncate() throws ExecutionException, InterruptedException {
76         testInserts(RoomDatabase.JournalMode.TRUNCATE);
77     }
78 
79     @Test
testInserts_JournalModeWAL()80     public void testInserts_JournalModeWAL() throws ExecutionException, InterruptedException {
81         testInserts(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING);
82     }
83 
testInserts(RoomDatabase.JournalMode journalMode)84     private void testInserts(RoomDatabase.JournalMode journalMode)
85             throws ExecutionException, InterruptedException {
86         testInserts(journalMode, true);
87         testInserts(journalMode, false);
88     }
89 
testInserts(RoomDatabase.JournalMode journalMode, boolean multiInstance)90     private void testInserts(RoomDatabase.JournalMode journalMode, boolean multiInstance)
91             throws ExecutionException, InterruptedException {
92         final RoomDatabase.Builder<DB> dbBuilder = Room
93                 // We need a physical DB to more easily reproduce invalidation callback errors,
94                 // and to support enableMultiInstanceInvalidation, which in turn helps reproduce
95                 // missed invalidation callbacks
96                 .databaseBuilder(ApplicationProvider.getApplicationContext(), DB.class, DB.NAME)
97                 .setJournalMode(journalMode);
98         if (multiInstance) {
99             // Helps reproduce missed invalidation callbacks
100             dbBuilder.enableMultiInstanceInvalidation();
101         }
102 
103         DB db = dbBuilder.build();
104 
105         try {
106             testInserts(db, 30, 0L, 0);
107             testInserts(db, 30, 0L, 10);
108             testInserts(db, 30, 0L, 100);
109             testInserts(db, 30, 0L, 1_000);
110             testInserts(db, 30, 0L, 10_000);
111             testInserts(db, 30, 0L, 100_000);
112             testInserts(db, 30, 1L, 0);
113         } finally {
114             db.close();
115             ApplicationProvider.getApplicationContext().deleteDatabase(DB.NAME);
116         }
117     }
118 
119     /**
120      * Uses repetitions within the test to better approximate real-life behavior, rather than
121      * scheduling the whole test for repeated runs from the outside.
122      */
testInserts( final DB db, final int iterations, final long delayMillis, final int delayNanos )123     private void testInserts(
124             final DB db, final int iterations, final long delayMillis, final int delayNanos
125     ) throws ExecutionException, InterruptedException {
126         final AtomicInteger missedInvalidations = new AtomicInteger();
127         final AtomicInteger spuriousInvalidations = new AtomicInteger();
128 
129         // Does not terminate execution as soon as a problem is detected, for simplicity.
130         // Usually there should not be a problem; termination is delayed only when there is a
131         // problem.
132         mExecutorService.submit(new Runnable() {
133             volatile @Nullable CountDownLatch mLatch = null;
134 
135             // Releases latch when change notification received, increments
136             // spuriousInvalidations when notification received without a recent change
137             final InvalidationTracker.Observer mInvalidationObserver =
138                     new InvalidationTracker.Observer(Counter2.TABLE_NAME) {
139                         @Override
140                         public void onInvalidated(@NonNull Set<String> tables) {
141                             if (tables.contains(Counter2.TABLE_NAME)) {
142                                 // Reading the latch field value is a bit racy,
143                                 // but it does not matter:
144                                 //
145                                 // If we see null here then we're either too early or too late;
146                                 // too late means that our long delay was too short, so we'd
147                                 // need to adjust it because now the test failed.
148                                 // Too early means that we received a spurious invalidation, so
149                                 // we need to fail the test.
150                                 //
151                                 // If we see non-null here instead of null due to a race then
152                                 // our long delay was just too short and we'll need to adjust it
153                                 // because the test will have failed. latch.countDown() happens
154                                 // too late in this case but it has no particular effect.
155                                 final CountDownLatch latch = mLatch;
156                                 if (latch == null) {
157                                     // Spurious invalidation callback; this might occur due to a
158                                     // large delay beyond the provisioned margin, or due to a
159                                     // bug in the code under test
160                                     spuriousInvalidations.incrementAndGet();
161                                 } else {
162                                     latch.countDown();
163                                 }
164                             }
165                         }
166                     };
167 
168             @Override
169             public void run() {
170                 // Ulterior use of this background thread to add the observer, which is not
171                 // legal to do from main thread.
172                 // To be close to a real use case we only register the observer once,
173                 // we do not re-register for each loop iteration.
174                 db.getInvalidationTracker().addObserver(mInvalidationObserver);
175 
176                 try {
177                     // Resets latch and updates missedInvalidations when change notification failed
178                     for (int i = 0; i < iterations; ++i) {
179                         // The Counter1 table exists just to make InvalidationTracker's life more
180                         // difficult, we are not interested in notifications from this one;
181                         // inserts may trigger undefined invalidation callback behavior,
182                         // depending on table update timing
183                         db.counterDao().insert(new Counter1());
184 
185                         // Use variable delay to detect different kinds of timing-related problems
186                         try {
187                             Thread.sleep(delayMillis, delayNanos);
188                         } catch (InterruptedException e) {
189                             throw new RuntimeException(e);
190                         }
191 
192                         final CountDownLatch latch = new CountDownLatch(1);
193 
194                         db.runInTransaction(() -> {
195                             db.counterDao().insert(new Counter2());
196 
197                             // Flag that we have inserted a new value, expect invalidation callback;
198                             // do this as late as possible prior to the end of the transaction;
199                             // this might cause an occasional false negative due to a race,
200                             // where a buggy InvalidationTracker could log successful tracking
201                             // even though the transaction is not completed yet, but it does not
202                             // matter much, as this is an intentionally flaky test; on another run
203                             // it should become apparent that InvalidationTracker is buggy.
204                             mLatch = latch;
205                         });
206 
207                         // Use sufficient delay to give invalidation tracker ample time to catch up;
208                         // this would need to be increased if the test had false positives.
209                         try {
210                             if (!latch.await(10L, TimeUnit.SECONDS)) {
211                                 // The tracker still has not been called, log an error
212                                 missedInvalidations.incrementAndGet();
213                             }
214                         } catch (InterruptedException e) {
215                             throw new RuntimeException(e);
216                         }
217 
218                         mLatch = null;
219                     }
220                 } finally {
221                     db.getInvalidationTracker().removeObserver(mInvalidationObserver);
222                 }
223             }
224         }).get();
225 
226         assertThat("Missed invalidations on " + iterations + " iterations with delay of " +
227                 delayMillis + " ms, " + delayNanos + " ns", missedInvalidations.get(), is(0));
228         assertThat("Spurious invalidations on " + iterations + " iterations with delay of " +
229                 delayMillis + " ms, " + delayNanos + " ns", spuriousInvalidations.get(), is(0));
230     }
231 
232     @Database(entities = { Counter1.class, Counter2.class }, version = 1, exportSchema = false)
233     abstract static class DB extends RoomDatabase {
234         static final String NAME = "invalidationtrackerbehavioraltest";
235 
counterDao()236         abstract CounterDao counterDao();
237     }
238 
239     @Entity(tableName = Counter1.TABLE_NAME)
240     static final class Counter1 {
241         static final String TABLE_NAME = "counter1";
242 
243         @PrimaryKey(autoGenerate = true)
244         long value;
245     }
246 
247     @Entity(tableName = Counter2.TABLE_NAME)
248     static final class Counter2 {
249         static final String TABLE_NAME = "counter2";
250 
251         @PrimaryKey(autoGenerate = true)
252         long value;
253     }
254 
255     @Dao
256     abstract static class CounterDao {
257         @Insert
insert(Counter1 entity)258         abstract void insert(Counter1 entity);
259 
260         @Insert
insert(Counter2 entity)261         abstract void insert(Counter2 entity);
262     }
263 }
264