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