1 /*
2 * Licensed to the Apache Software Foundation (ASF) under one
3 * or more contributor license agreements. See the NOTICE file
4 * distributed with this work for additional information
5 * regarding copyright ownership. The ASF licenses this file
6 * to you under the Apache License, Version 2.0 (the
7 * "License"); you may not use this file except in compliance
8 * with the License. You may obtain a copy of the License at
9 *
10 * http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing,
13 * software distributed under the License is distributed on an
14 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15 * KIND, either express or implied. See the License for the
16 * specific language governing permissions and limitations
17 * under the License.
18 */
19 package org.eclipse.aether.named.providers;
20
21 import javax.inject.Named;
22 import javax.inject.Singleton;
23
24 import java.io.IOException;
25 import java.io.UncheckedIOException;
26 import java.net.URI;
27 import java.nio.channels.FileChannel;
28 import java.nio.file.Files;
29 import java.nio.file.Path;
30 import java.nio.file.Paths;
31 import java.nio.file.StandardOpenOption;
32 import java.util.Iterator;
33 import java.util.LinkedHashMap;
34 import java.util.Map;
35 import java.util.concurrent.ConcurrentHashMap;
36 import java.util.concurrent.ConcurrentMap;
37
38 import org.eclipse.aether.named.NamedLock;
39 import org.eclipse.aether.named.NamedLockKey;
40 import org.eclipse.aether.named.support.FileLockNamedLock;
41 import org.eclipse.aether.named.support.NamedLockFactorySupport;
42 import org.eclipse.aether.named.support.NamedLockSupport;
43
44 import static org.eclipse.aether.named.support.Retry.retry;
45
46 /**
47 * Named locks factory of {@link FileLockNamedLock}s. This is a bit of special implementation, as it
48 * expects locks names to be proper URI string representations (use {@code file:} protocol for default
49 * file system).
50 *
51 * @since 1.7.3
52 */
53 @Singleton
54 @Named(FileLockNamedLockFactory.NAME)
55 public class FileLockNamedLockFactory extends NamedLockFactorySupport {
56 public static final String NAME = "file-lock";
57
58 // Logic borrowed from Commons-Lang3: we really need only this, to decide do we "delete on close" or not
59 private static final boolean IS_WINDOWS =
60 System.getProperty("os.name", "unknown").startsWith("Windows");
61
62 /**
63 * Tweak: on Windows, the presence of <em>StandardOpenOption#DELETE_ON_CLOSE</em> causes concurrency issues. This
64 * flag allows to have it removed from effective flags, at the cost that lockfile directory becomes crowded
65 * with 0 byte sized lock files that are never cleaned up. Default value is {@code true} on non-Windows OS.
66 * See <a href="https://bugs.openjdk.org/browse/JDK-8252883">JDK-8252883</a> for Windows related bug. Users
67 * on Windows can still force "delete on close" by explicitly setting this property to {@code true}.
68 *
69 * @see <a href="https://bugs.openjdk.org/browse/JDK-8252883">JDK-8252883</a>
70 * @configurationSource {@link System#getProperty(String, String)}
71 * @configurationType {@link java.lang.Boolean}
72 * @configurationDefaultValue true
73 */
74 public static final String SYSTEM_PROP_DELETE_LOCK_FILES = "aether.named.file-lock.deleteLockFiles";
75
76 private static final boolean DELETE_LOCK_FILES =
77 Boolean.parseBoolean(System.getProperty(SYSTEM_PROP_DELETE_LOCK_FILES, Boolean.toString(!IS_WINDOWS)));
78
79 /**
80 * Tweak: on Windows, the presence of <em>StandardOpenOption#DELETE_ON_CLOSE</em> causes concurrency issues. This
81 * flag allows to implement similar fix as referenced JDK bug report: retry and hope the best. Default value is
82 * 5 attempts (will retry 4 times).
83 *
84 * @see <a href="https://bugs.openjdk.org/browse/JDK-8252883">JDK-8252883</a>
85 * @configurationSource {@link System#getProperty(String, String)}
86 * @configurationType {@link java.lang.Integer}
87 * @configurationDefaultValue 5
88 */
89 public static final String SYSTEM_PROP_ATTEMPTS = "aether.named.file-lock.attempts";
90
91 private static final int ATTEMPTS = Integer.parseInt(System.getProperty(SYSTEM_PROP_ATTEMPTS, "5"));
92
93 /**
94 * Tweak: When {@link #SYSTEM_PROP_ATTEMPTS} used, the amount of milliseconds to sleep between subsequent retries. Default
95 * value is 50 milliseconds.
96 *
97 * @configurationSource {@link System#getProperty(String, String)}
98 * @configurationType {@link java.lang.Long}
99 * @configurationDefaultValue 50
100 */
101 public static final String SYSTEM_PROP_SLEEP_MILLIS = "aether.named.file-lock.sleepMillis";
102
103 private static final long SLEEP_MILLIS = Long.parseLong(System.getProperty(SYSTEM_PROP_SLEEP_MILLIS, "50"));
104
105 /**
106 * Maximum number of idle (not currently locked) FileChannels to keep open for reuse. Keeping channels open
107 * avoids repeated {@code open}/{@code creat} syscalls, but each open channel consumes a file descriptor.
108 * On systems with low FD limits (e.g., macOS defaults to 256), large reactor builds with thousands of
109 * unique artifacts can exhaust the limit. This cap bounds idle channel retention; channels for actively
110 * held locks are never evicted.
111 *
112 * @configurationSource {@link System#getProperty(String, String)}
113 * @configurationType {@link java.lang.Integer}
114 * @configurationDefaultValue 200
115 */
116 public static final String SYSTEM_PROP_MAX_CACHED_CHANNELS = "aether.named.file-lock.maxCachedChannels";
117
118 private static final int MAX_CACHED_CHANNELS =
119 Integer.parseInt(System.getProperty(SYSTEM_PROP_MAX_CACHED_CHANNELS, "200"));
120
121 private final ConcurrentMap<NamedLockKey, FileChannel> fileChannels;
122
123 /**
124 * LRU pool of idle (unlocked) channels available for reuse. Access-ordered: the least recently used
125 * entry is evicted first when the pool exceeds {@link #MAX_CACHED_CHANNELS}. Guarded by its own
126 * monitor; never held while performing I/O.
127 */
128 private final LinkedHashMap<NamedLockKey, FileChannel> idleChannels;
129
130 public FileLockNamedLockFactory() {
131 this.fileChannels = new ConcurrentHashMap<>();
132 this.idleChannels = new LinkedHashMap<>(64, 0.75f, true); // access-order
133 }
134
135 @Override
136 protected NamedLockSupport createLock(final NamedLockKey key) {
137 Path path = Paths.get(URI.create(key.name()));
138 // Try to reclaim an idle channel first (avoids open syscall)
139 FileChannel fileChannel;
140 synchronized (idleChannels) {
141 fileChannel = idleChannels.remove(key);
142 }
143 if (fileChannel != null && fileChannel.isOpen()) {
144 fileChannels.put(key, fileChannel);
145 } else {
146 fileChannel = fileChannels.computeIfAbsent(key, k -> openFileChannel(key, path));
147 if (!fileChannel.isOpen()) {
148 // Channel was closed externally (I/O error, NFS hiccup, etc.). Evict the stale entry
149 // and open a fresh one. remove(key, fileChannel) is atomic: it only removes if the
150 // value is still this exact (stale) instance, avoiding races with other threads that
151 // may have already replaced it.
152 fileChannels.remove(key, fileChannel);
153 fileChannel = fileChannels.computeIfAbsent(key, k -> openFileChannel(key, path));
154 }
155 }
156 return new FileLockNamedLock(key, fileChannel, this);
157 }
158
159 private FileChannel openFileChannel(NamedLockKey key, Path path) {
160 try {
161 Files.createDirectories(path.getParent());
162 FileChannel channel = retry(
163 ATTEMPTS,
164 SLEEP_MILLIS,
165 () -> {
166 if (DELETE_LOCK_FILES) {
167 return FileChannel.open(
168 path,
169 StandardOpenOption.READ,
170 StandardOpenOption.WRITE,
171 StandardOpenOption.CREATE,
172 StandardOpenOption.DELETE_ON_CLOSE);
173 } else {
174 return FileChannel.open(
175 path, StandardOpenOption.READ, StandardOpenOption.WRITE, StandardOpenOption.CREATE);
176 }
177 },
178 null,
179 null);
180
181 if (channel == null) {
182 throw new IllegalStateException(
183 "Could not open file channel for '" + key + "' after " + ATTEMPTS + " attempts; giving up");
184 }
185 return channel;
186 } catch (InterruptedException e) {
187 Thread.currentThread().interrupt();
188 throw new RuntimeException("Interrupted while opening file channel for '" + key + "'", e);
189 } catch (IOException e) {
190 throw new UncheckedIOException("Failed to open file channel for '" + key + "'", e);
191 }
192 }
193
194 @Override
195 protected void destroyLock(final NamedLock namedLock) {
196 NamedLockKey key = namedLock.key();
197 FileChannel channel = fileChannels.remove(key);
198 if (channel == null) {
199 return;
200 }
201 // Move the channel to the idle pool for reuse by future createLock() calls.
202 // Evict the least recently used idle channel if the pool is full.
203 FileChannel evicted = null;
204 synchronized (idleChannels) {
205 idleChannels.put(key, channel);
206 if (idleChannels.size() > MAX_CACHED_CHANNELS) {
207 Iterator<Map.Entry<NamedLockKey, FileChannel>> it =
208 idleChannels.entrySet().iterator();
209 evicted = it.next().getValue();
210 it.remove();
211 }
212 }
213 if (evicted != null) {
214 try {
215 evicted.close();
216 } catch (IOException e) {
217 logger.warn("Failed to close evicted file channel", e);
218 }
219 }
220 }
221
222 @Override
223 protected void doShutdown() {
224 for (FileChannel channel : fileChannels.values()) {
225 try {
226 channel.close();
227 } catch (IOException e) {
228 logger.warn("Failed to close file channel", e);
229 }
230 }
231 fileChannels.clear();
232 synchronized (idleChannels) {
233 for (FileChannel channel : idleChannels.values()) {
234 try {
235 channel.close();
236 } catch (IOException e) {
237 logger.warn("Failed to close idle file channel", e);
238 }
239 }
240 idleChannels.clear();
241 }
242 }
243 }