• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2022 Google LLC
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 package com.google.android.libraries.mobiledatadownload.file.common;
17 
18 import android.net.Uri;
19 import android.os.SystemClock;
20 import com.google.android.libraries.mobiledatadownload.file.common.internal.ExponentialBackoffIterator;
21 import com.google.common.base.Optional;
22 import java.io.Closeable;
23 import java.io.IOException;
24 import java.io.InterruptedIOException;
25 import java.nio.channels.FileChannel;
26 import java.nio.channels.FileLock;
27 import java.util.Iterator;
28 import java.util.concurrent.ConcurrentHashMap;
29 import java.util.concurrent.ConcurrentMap;
30 import java.util.concurrent.Semaphore;
31 import javax.annotation.Nullable;
32 
33 /**
34  * An implementation of {@link Lock} based on a Java channel {@link FileLock} and Semaphores. It
35  * handles multi-thread and multi-process exclusion.
36  *
37  * <p>NOTE: Multi-thread exclusion is not supported natively by Java (See {@link
38  * https://docs.oracle.com/javase/7/docs/api/java/nio/channels/FileChannel.html}), but it is
39  * provided here. For it to work properly, a map from file name to Semaphore is maintained. If the
40  * scope of that map is not big enough (eg, if the map is maintained in a Backend, but there are
41  * multiple Backend instances accessing the same file), it is still possible to get a
42  * OverlappingFileLockException.
43  *
44  * <p>NOTE: The keys in the semaphore map are generated from the File or Uri string. No attempt is
45  * made to canonicalize those strings, or deal with other corner cases like hard links.
46  *
47  * <p>TODO: Implemented shared thread locks if needed.
48  */
49 public final class LockScope {
50 
51   // NOTE(b/254717998): due to the design of Linux file lock, it would throw an IOException with
52   // "Resource deadlock would occur" as false alarms in some use cases. As the fix, in the case of
53   // such failures where error message matches with {@link DEADLOCK_ERROR_MESSAGE}, we first do
54   // exponential backoff to retry to get file lock, and then retry every second until it succeeds.
55   private static final String DEADLOCK_ERROR_MESSAGE = "Resource deadlock would occur";
56 
57   // Wait for 10 ms if need to retry file locking for the first time
58   private static final Long INITIAL_WAIT_MILLIS = 10L;
59   // Wait for 1 minute if need to retry file locking with the upper bound wait time
60   private static final Long UPPER_BOUND_WAIT_MILLIS = 60_000L;
61 
62   @Nullable private final ConcurrentMap<String, Semaphore> lockMap;
63 
64   /**
65    * @deprecated Prefer the static {@link create()} factory method.
66    */
67   @Deprecated
LockScope()68   public LockScope() {
69     this(new ConcurrentHashMap<>());
70   }
71 
LockScope(ConcurrentMap<String, Semaphore> lockMap)72   private LockScope(ConcurrentMap<String, Semaphore> lockMap) {
73     this.lockMap = lockMap;
74   }
75 
76   /** Returns a new instance. */
create()77   public static LockScope create() {
78     return new LockScope(new ConcurrentHashMap<>());
79   }
80 
81   /**
82    * Returns a new instance that will use the given map for lock leasing. This is only necessary if
83    * {@code LockScope} can't be a managed as a singleton but {@code lockMap} can be.
84    */
createWithExistingThreadLocks(ConcurrentMap<String, Semaphore> lockMap)85   public static LockScope createWithExistingThreadLocks(ConcurrentMap<String, Semaphore> lockMap) {
86     return new LockScope(lockMap);
87   }
88 
89   /** Returns a new instance that will always fail to acquire thread locks. */
createWithFailingThreadLocks()90   public static LockScope createWithFailingThreadLocks() {
91     return new LockScope(null);
92   }
93 
94   /** Acquires a cross-thread lock on {@code uri}. This blocks until the lock is obtained. */
threadLock(Uri uri)95   public Lock threadLock(Uri uri) throws IOException {
96     if (!threadLocksAreAvailable()) {
97       throw new UnsupportedFileStorageOperation("Couldn't acquire lock");
98     }
99 
100     Semaphore semaphore = getOrCreateSemaphore(uri.toString());
101     try (SemaphoreResource semaphoreResource = SemaphoreResource.acquire(semaphore)) {
102       return new SemaphoreLockImpl(semaphoreResource.releaseFromTryBlock());
103     }
104   }
105 
106   /**
107    * Attempts to acquire a cross-thread lock on {@code uri}. This does not block, and returns null
108    * if the lock cannot be obtained immediately.
109    */
110   @Nullable
tryThreadLock(Uri uri)111   public Lock tryThreadLock(Uri uri) throws IOException {
112     if (!threadLocksAreAvailable()) {
113       return null;
114     }
115 
116     Semaphore semaphore = getOrCreateSemaphore(uri.toString());
117     try (SemaphoreResource semaphoreResource = SemaphoreResource.tryAcquire(semaphore)) {
118       if (!semaphoreResource.acquired()) {
119         return null;
120       }
121       return new SemaphoreLockImpl(semaphoreResource.releaseFromTryBlock());
122     }
123   }
124 
125   /** Acquires a cross-process lock on {@code channel}. This blocks until the lock is obtained. */
fileLock(FileChannel channel, boolean shared)126   public Lock fileLock(FileChannel channel, boolean shared) throws IOException {
127     Optional<FileLockImpl> fileLock = fileLockAndThrowIfNotDeadlock(channel, shared);
128     if (fileLock.isPresent()) {
129       return fileLock.get();
130     }
131 
132     // if an IOException with "Resource deadlock would occur" is thrown from getting file lock, we
133     // will keep retrying until it succeeds
134     Iterator<Long> retryIterator =
135         ExponentialBackoffIterator.create(INITIAL_WAIT_MILLIS, UPPER_BOUND_WAIT_MILLIS);
136     // TODO(b/254717998): error after a number of retry attempts if needed. And possibly detect real
137     // deadlocks in client use cases.
138     while (retryIterator.hasNext()) {
139       long waitTime = retryIterator.next();
140       SystemClock.sleep(waitTime);
141 
142       Optional<FileLockImpl> fileLockImpl = fileLockAndThrowIfNotDeadlock(channel, shared);
143       if (fileLockImpl.isPresent()) {
144         return fileLockImpl.get();
145       }
146     }
147     // should never reach here because ExponentialBackoffIterator guarantees it will always hasNext,
148     // make builder happy
149     throw new IllegalStateException("should have gotten file lock and returned");
150   }
151 
152   /**
153    * Attempts to acquire a cross-process lock on {@code channel}. This does not block, and returns
154    * null if the lock cannot be obtained immediately.
155    */
156   @Nullable
tryFileLock(FileChannel channel, boolean shared)157   public Lock tryFileLock(FileChannel channel, boolean shared) throws IOException {
158     try {
159       FileLock lock = channel.tryLock(0L /* position */, Long.MAX_VALUE /* size */, shared);
160       if (lock == null) {
161         return null;
162       }
163       return new FileLockImpl(lock);
164     } catch (IOException ex) {
165       // Android throws IOException with message "fcntl failed: EAGAIN (Try again)" instead
166       // of returning null as expected.
167       return null;
168     }
169   }
170 
threadLocksAreAvailable()171   private boolean threadLocksAreAvailable() {
172     return lockMap != null;
173   }
174 
175   /**
176    * Returns the file lock got from given channel. If gets an IOException with {@link
177    * DEADLOCK_ERROR_MESSAGE}, returns empty; otherwise throws the error.
178    */
fileLockAndThrowIfNotDeadlock( FileChannel channel, boolean shared)179   private static Optional<FileLockImpl> fileLockAndThrowIfNotDeadlock(
180       FileChannel channel, boolean shared) throws IOException {
181     try {
182       FileLock lock = channel.lock(0L /* position */, Long.MAX_VALUE /* size */, shared);
183       return Optional.of(new FileLockImpl(lock));
184     } catch (IOException ex) {
185       if (!ex.getMessage().contains(DEADLOCK_ERROR_MESSAGE)) {
186         throw ex;
187       }
188       return Optional.absent();
189     }
190   }
191 
192   private static class FileLockImpl implements Lock {
193 
194     private FileLock fileLock;
195 
FileLockImpl(FileLock fileLock)196     public FileLockImpl(FileLock fileLock) {
197       this.fileLock = fileLock;
198     }
199 
200     @Override
release()201     public void release() throws IOException {
202       if (fileLock != null) {
203         fileLock.release();
204         fileLock = null;
205       }
206     }
207 
208     @Override
isValid()209     public boolean isValid() {
210       return fileLock.isValid();
211     }
212 
213     @Override
isShared()214     public boolean isShared() {
215       return fileLock.isShared();
216     }
217 
218     @Override
close()219     public void close() throws IOException {
220       release();
221     }
222   }
223 
224   private static class SemaphoreLockImpl implements Lock {
225 
226     private Semaphore semaphore;
227 
SemaphoreLockImpl(Semaphore semaphore)228     SemaphoreLockImpl(Semaphore semaphore) {
229       this.semaphore = semaphore;
230     }
231 
232     @Override
release()233     public void release() throws IOException {
234       if (semaphore != null) {
235         semaphore.release();
236         semaphore = null;
237       }
238     }
239 
240     @Override
isValid()241     public boolean isValid() {
242       return semaphore != null;
243     }
244 
245     /** Semaphore locks are always exclusive. */
246     @Override
isShared()247     public boolean isShared() {
248       return false;
249     }
250 
251     @Override
close()252     public void close() throws IOException {
253       release();
254     }
255   }
256 
257   // SemaphoreResource similar to ReleaseableResource that handles both releasing and implementing
258   // closeable.
259   private static class SemaphoreResource implements Closeable {
260     @Nullable private Semaphore semaphore;
261 
tryAcquire(Semaphore semaphore)262     static SemaphoreResource tryAcquire(Semaphore semaphore) {
263       boolean acquired = semaphore.tryAcquire();
264       return new SemaphoreResource(acquired ? semaphore : null);
265     }
266 
acquire(Semaphore semaphore)267     static SemaphoreResource acquire(Semaphore semaphore) throws InterruptedIOException {
268       try {
269         semaphore.acquire();
270       } catch (InterruptedException ex) {
271         throw new InterruptedIOException("semaphore not acquired: " + ex);
272       }
273       return new SemaphoreResource(semaphore);
274     }
275 
SemaphoreResource(@ullable Semaphore semaphore)276     SemaphoreResource(@Nullable Semaphore semaphore) {
277       this.semaphore = semaphore;
278     }
279 
acquired()280     boolean acquired() {
281       return (semaphore != null);
282     }
283 
releaseFromTryBlock()284     Semaphore releaseFromTryBlock() {
285       Semaphore result = semaphore;
286       semaphore = null;
287       return result;
288     }
289 
290     @Override
close()291     public void close() {
292       if (semaphore != null) {
293         semaphore.release();
294         semaphore = null;
295       }
296     }
297   }
298 
getOrCreateSemaphore(String key)299   private Semaphore getOrCreateSemaphore(String key) {
300     // NOTE: Entries added to this lockMap are never removed. If a large, varying number of
301     // files are locked, adding a mechanism delete obsolete entries in the table would be desirable.
302     // That is not the case now.
303     Semaphore semaphore = lockMap.get(key);
304     if (semaphore == null) {
305       lockMap.putIfAbsent(key, new Semaphore(1));
306       semaphore = lockMap.get(key); // Re-get() in case another thread putIfAbsent() before us.
307     }
308     return semaphore;
309   }
310 }
311