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.sqlite.inspection;
18 
19 import android.database.sqlite.SQLiteDatabase;
20 import android.os.CancellationSignal;
21 
22 import androidx.annotation.GuardedBy;
23 import androidx.annotation.VisibleForTesting;
24 import androidx.sqlite.inspection.SqliteInspector.DatabaseConnection;
25 
26 import org.jspecify.annotations.NonNull;
27 import org.jspecify.annotations.Nullable;
28 
29 import java.util.HashMap;
30 import java.util.Map;
31 import java.util.concurrent.Executor;
32 import java.util.concurrent.Executors;
33 import java.util.concurrent.Future;
34 import java.util.concurrent.ThreadFactory;
35 import java.util.concurrent.TimeUnit;
36 
37 /**
38  * Handles database locking and associated bookkeeping.
39  * Thread-safe.
40  */
41 public class DatabaseLockRegistry {
42     @VisibleForTesting public static int sTimeoutMs = 5000;
43 
44     private final Object mLock = new Object(); // used for synchronization within the class
45     @GuardedBy("mLock") private final Map<Integer, Lock> mLockIdToLockMap = new HashMap<>();
46     @GuardedBy("mLock") private final Map<Integer, Lock> mDatabaseIdToLockMap = new HashMap<>();
47     @GuardedBy("mLock") private int mNextLockId = 1;
48 
49     // A dedicated thread required as database transactions are tied to a thread. In order to
50     // release a lock, we need to use the same thread as the one we used to establish the lock.
51     // Thread names need to start with 'Studio:' as per some framework limitations.
52     private final @NonNull Executor mExecutor =
53             Executors.newSingleThreadExecutor(new ThreadFactory() {
54                 @Override
55                 public Thread newThread(Runnable r) {
56                     Thread thread = new Thread(r, "Studio:Sql:Lock"); // limit = 15 characters
57                     thread.setDaemon(true);
58                     return thread;
59                 }
60             });
61 
62     /**
63      * Locks a database identified by the provided database id. If a lock on the database is
64      * already in place, an existing lock will be issued. Locks keep count of simultaneous
65      * requests, so that the database is only unlocked once all callers release their issued locks.
66      */
acquireLock(int databaseId, @NonNull SQLiteDatabase database)67     public int acquireLock(int databaseId, @NonNull SQLiteDatabase database) throws Exception {
68         synchronized (mLock) {
69             Lock lock = mDatabaseIdToLockMap.get(databaseId);
70             if (lock == null) {
71                 lock = new Lock(mNextLockId++, databaseId, database);
72                 lockDatabase(lock.mDatabase);
73                 mLockIdToLockMap.put(lock.mLockId, lock);
74                 mDatabaseIdToLockMap.put(lock.mDatabaseId, lock);
75             }
76             lock.mCount++;
77             return lock.mLockId;
78         }
79     }
80 
81     /**
82      * Releases a lock on a database identified by the provided lock id. If the same lock has been
83      * provided multiple times (for lock requests on an already locked database), the lock
84      * needs to be released by all previous requestors for the database to get unlocked.
85      */
releaseLock(int lockId)86     public void releaseLock(int lockId) throws Exception {
87         synchronized (mLock) {
88             Lock lock = mLockIdToLockMap.get(lockId);
89             if (lock == null) throw new IllegalArgumentException("No lock with id: " + lockId);
90 
91             if (--lock.mCount == 0) {
92                 try {
93                     unlockDatabase(lock.mDatabase);
94                 } catch (Exception e) {
95                     lock.mCount++; // correct the count
96                     throw e;
97                 }
98                 mLockIdToLockMap.remove(lock.mLockId);
99                 mDatabaseIdToLockMap.remove(lock.mDatabaseId);
100             }
101         }
102     }
103 
104     /**
105      * @return `null` if the database is not locked; the database and the executor that locked the
106      * database otherwise
107      */
getConnection(int databaseId)108     @Nullable DatabaseConnection getConnection(int databaseId) {
109         synchronized (mLock) {
110             Lock lock = mDatabaseIdToLockMap.get(databaseId);
111             return (lock == null)
112                     ? null
113                     : new DatabaseConnection(lock.mDatabase, mExecutor);
114         }
115     }
116 
117     /**
118      * Starts a database transaction and acquires an extra database reference to keep the database
119      * open while the lock is in place.
120      */
lockDatabase(final SQLiteDatabase database)121     private void lockDatabase(final SQLiteDatabase database) throws Exception {
122         // keeps the database open while a lock is in place; released when the lock is released
123         boolean keepOpenReferenceAcquired = false;
124 
125         final CancellationSignal cancellationSignal = new CancellationSignal();
126         Future<?> future = null;
127         try {
128             database.acquireReference();
129             keepOpenReferenceAcquired = true;
130 
131             // Submitting a Runnable, so we can set a timeout.
132             future = SqliteInspectionExecutors.submit(mExecutor, new Runnable() {
133                 @Override
134                 public void run() {
135                     // starts a transaction
136                     database.rawQuery("BEGIN IMMEDIATE;", new String[0], cancellationSignal)
137                             .getCount(); // forces the cursor to execute the query
138                 }
139             });
140             future.get(sTimeoutMs, TimeUnit.MILLISECONDS);
141         } catch (Exception e) {
142             if (keepOpenReferenceAcquired) database.releaseReference();
143             cancellationSignal.cancel();
144             if (future != null) future.cancel(true);
145             throw e;
146         }
147     }
148 
149     /**
150      * Ends the database transaction and releases the extra database reference that kept the
151      * database open while the lock was in place.
152      */
unlockDatabase(final SQLiteDatabase database)153     private void unlockDatabase(final SQLiteDatabase database) throws Exception {
154         final CancellationSignal cancellationSignal = new CancellationSignal();
155         Future<?> future = null;
156         try {
157             // Submitting a Runnable, so we can set a timeout.
158             future = SqliteInspectionExecutors.submit(mExecutor, new Runnable() {
159                 @Override
160                 public void run() {
161                     // ends the transaction
162                     database.rawQuery("ROLLBACK;", new String[0], cancellationSignal)
163                             .getCount(); // forces the cursor to execute the query
164                     database.releaseReference();
165                 }
166             });
167             future.get(sTimeoutMs, TimeUnit.MILLISECONDS);
168         } catch (Exception e) {
169             cancellationSignal.cancel();
170             if (future != null) future.cancel(true);
171             throw e;
172         }
173     }
174 
175     private static final class Lock {
176         final int mLockId;
177         final int mDatabaseId;
178         final SQLiteDatabase mDatabase;
179         int mCount = 0; // number of simultaneous locks secured on the database
180 
Lock(int lockId, int databaseId, SQLiteDatabase database)181         Lock(int lockId, int databaseId, SQLiteDatabase database) {
182             this.mLockId = lockId;
183             this.mDatabaseId = databaseId;
184             this.mDatabase = database;
185         }
186     }
187 }
188