summaryrefslogtreecommitdiff
path: root/java/com/google/android/libraries/mobiledatadownload/file/common/LockScope.java
blob: 2c6811160232b61c44f1edad224991910a70923e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
/*
 * Copyright 2022 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.google.android.libraries.mobiledatadownload.file.common;

import android.net.Uri;
import android.os.SystemClock;
import com.google.android.libraries.mobiledatadownload.file.common.internal.ExponentialBackoffIterator;
import com.google.common.base.Optional;
import java.io.Closeable;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Semaphore;
import javax.annotation.Nullable;

/**
 * An implementation of {@link Lock} based on a Java channel {@link FileLock} and Semaphores. It
 * handles multi-thread and multi-process exclusion.
 *
 * <p>NOTE: Multi-thread exclusion is not supported natively by Java (See {@link
 * https://docs.oracle.com/javase/7/docs/api/java/nio/channels/FileChannel.html}), but it is
 * provided here. For it to work properly, a map from file name to Semaphore is maintained. If the
 * scope of that map is not big enough (eg, if the map is maintained in a Backend, but there are
 * multiple Backend instances accessing the same file), it is still possible to get a
 * OverlappingFileLockException.
 *
 * <p>NOTE: The keys in the semaphore map are generated from the File or Uri string. No attempt is
 * made to canonicalize those strings, or deal with other corner cases like hard links.
 *
 * <p>TODO: Implemented shared thread locks if needed.
 */
public final class LockScope {

  // NOTE(b/254717998): due to the design of Linux file lock, it would throw an IOException with
  // "Resource deadlock would occur" as false alarms in some use cases. As the fix, in the case of
  // such failures where error message matches with {@link DEADLOCK_ERROR_MESSAGE}, we first do
  // exponential backoff to retry to get file lock, and then retry every second until it succeeds.
  private static final String DEADLOCK_ERROR_MESSAGE = "Resource deadlock would occur";

  // Wait for 10 ms if need to retry file locking for the first time
  private static final Long INITIAL_WAIT_MILLIS = 10L;
  // Wait for 1 minute if need to retry file locking with the upper bound wait time
  private static final Long UPPER_BOUND_WAIT_MILLIS = 60_000L;

  @Nullable private final ConcurrentMap<String, Semaphore> lockMap;

  /**
   * @deprecated Prefer the static {@link create()} factory method.
   */
  @Deprecated
  public LockScope() {
    this(new ConcurrentHashMap<>());
  }

  private LockScope(ConcurrentMap<String, Semaphore> lockMap) {
    this.lockMap = lockMap;
  }

  /** Returns a new instance. */
  public static LockScope create() {
    return new LockScope(new ConcurrentHashMap<>());
  }

  /**
   * Returns a new instance that will use the given map for lock leasing. This is only necessary if
   * {@code LockScope} can't be a managed as a singleton but {@code lockMap} can be.
   */
  public static LockScope createWithExistingThreadLocks(ConcurrentMap<String, Semaphore> lockMap) {
    return new LockScope(lockMap);
  }

  /** Returns a new instance that will always fail to acquire thread locks. */
  public static LockScope createWithFailingThreadLocks() {
    return new LockScope(null);
  }

  /** Acquires a cross-thread lock on {@code uri}. This blocks until the lock is obtained. */
  public Lock threadLock(Uri uri) throws IOException {
    if (!threadLocksAreAvailable()) {
      throw new UnsupportedFileStorageOperation("Couldn't acquire lock");
    }

    Semaphore semaphore = getOrCreateSemaphore(uri.toString());
    try (SemaphoreResource semaphoreResource = SemaphoreResource.acquire(semaphore)) {
      return new SemaphoreLockImpl(semaphoreResource.releaseFromTryBlock());
    }
  }

  /**
   * Attempts to acquire a cross-thread lock on {@code uri}. This does not block, and returns null
   * if the lock cannot be obtained immediately.
   */
  @Nullable
  public Lock tryThreadLock(Uri uri) throws IOException {
    if (!threadLocksAreAvailable()) {
      return null;
    }

    Semaphore semaphore = getOrCreateSemaphore(uri.toString());
    try (SemaphoreResource semaphoreResource = SemaphoreResource.tryAcquire(semaphore)) {
      if (!semaphoreResource.acquired()) {
        return null;
      }
      return new SemaphoreLockImpl(semaphoreResource.releaseFromTryBlock());
    }
  }

  /** Acquires a cross-process lock on {@code channel}. This blocks until the lock is obtained. */
  public Lock fileLock(FileChannel channel, boolean shared) throws IOException {
    Optional<FileLockImpl> fileLock = fileLockAndThrowIfNotDeadlock(channel, shared);
    if (fileLock.isPresent()) {
      return fileLock.get();
    }

    // if an IOException with "Resource deadlock would occur" is thrown from getting file lock, we
    // will keep retrying until it succeeds
    Iterator<Long> retryIterator =
        ExponentialBackoffIterator.create(INITIAL_WAIT_MILLIS, UPPER_BOUND_WAIT_MILLIS);
    // TODO(b/254717998): error after a number of retry attempts if needed. And possibly detect real
    // deadlocks in client use cases.
    while (retryIterator.hasNext()) {
      long waitTime = retryIterator.next();
      SystemClock.sleep(waitTime);

      Optional<FileLockImpl> fileLockImpl = fileLockAndThrowIfNotDeadlock(channel, shared);
      if (fileLockImpl.isPresent()) {
        return fileLockImpl.get();
      }
    }
    // should never reach here because ExponentialBackoffIterator guarantees it will always hasNext,
    // make builder happy
    throw new IllegalStateException("should have gotten file lock and returned");
  }

  /**
   * Attempts to acquire a cross-process lock on {@code channel}. This does not block, and returns
   * null if the lock cannot be obtained immediately.
   */
  @Nullable
  public Lock tryFileLock(FileChannel channel, boolean shared) throws IOException {
    try {
      FileLock lock = channel.tryLock(0L /* position */, Long.MAX_VALUE /* size */, shared);
      if (lock == null) {
        return null;
      }
      return new FileLockImpl(lock);
    } catch (IOException ex) {
      // Android throws IOException with message "fcntl failed: EAGAIN (Try again)" instead
      // of returning null as expected.
      return null;
    }
  }

  private boolean threadLocksAreAvailable() {
    return lockMap != null;
  }

  /**
   * Returns the file lock got from given channel. If gets an IOException with {@link
   * DEADLOCK_ERROR_MESSAGE}, returns empty; otherwise throws the error.
   */
  private static Optional<FileLockImpl> fileLockAndThrowIfNotDeadlock(
      FileChannel channel, boolean shared) throws IOException {
    try {
      FileLock lock = channel.lock(0L /* position */, Long.MAX_VALUE /* size */, shared);
      return Optional.of(new FileLockImpl(lock));
    } catch (IOException ex) {
      if (!ex.getMessage().contains(DEADLOCK_ERROR_MESSAGE)) {
        throw ex;
      }
      return Optional.absent();
    }
  }

  private static class FileLockImpl implements Lock {

    private FileLock fileLock;

    public FileLockImpl(FileLock fileLock) {
      this.fileLock = fileLock;
    }

    @Override
    public void release() throws IOException {
      if (fileLock != null) {
        fileLock.release();
        fileLock = null;
      }
    }

    @Override
    public boolean isValid() {
      return fileLock.isValid();
    }

    @Override
    public boolean isShared() {
      return fileLock.isShared();
    }

    @Override
    public void close() throws IOException {
      release();
    }
  }

  private static class SemaphoreLockImpl implements Lock {

    private Semaphore semaphore;

    SemaphoreLockImpl(Semaphore semaphore) {
      this.semaphore = semaphore;
    }

    @Override
    public void release() throws IOException {
      if (semaphore != null) {
        semaphore.release();
        semaphore = null;
      }
    }

    @Override
    public boolean isValid() {
      return semaphore != null;
    }

    /** Semaphore locks are always exclusive. */
    @Override
    public boolean isShared() {
      return false;
    }

    @Override
    public void close() throws IOException {
      release();
    }
  }

  // SemaphoreResource similar to ReleaseableResource that handles both releasing and implementing
  // closeable.
  private static class SemaphoreResource implements Closeable {
    @Nullable private Semaphore semaphore;

    static SemaphoreResource tryAcquire(Semaphore semaphore) {
      boolean acquired = semaphore.tryAcquire();
      return new SemaphoreResource(acquired ? semaphore : null);
    }

    static SemaphoreResource acquire(Semaphore semaphore) throws InterruptedIOException {
      try {
        semaphore.acquire();
      } catch (InterruptedException ex) {
        throw new InterruptedIOException("semaphore not acquired: " + ex);
      }
      return new SemaphoreResource(semaphore);
    }

    SemaphoreResource(@Nullable Semaphore semaphore) {
      this.semaphore = semaphore;
    }

    boolean acquired() {
      return (semaphore != null);
    }

    Semaphore releaseFromTryBlock() {
      Semaphore result = semaphore;
      semaphore = null;
      return result;
    }

    @Override
    public void close() {
      if (semaphore != null) {
        semaphore.release();
        semaphore = null;
      }
    }
  }

  private Semaphore getOrCreateSemaphore(String key) {
    // NOTE: Entries added to this lockMap are never removed. If a large, varying number of
    // files are locked, adding a mechanism delete obsolete entries in the table would be desirable.
    // That is not the case now.
    Semaphore semaphore = lockMap.get(key);
    if (semaphore == null) {
      lockMap.putIfAbsent(key, new Semaphore(1));
      semaphore = lockMap.get(key); // Re-get() in case another thread putIfAbsent() before us.
    }
    return semaphore;
  }
}