View Javadoc
1   package org.eclipse.aether.synccontext;
2   
3   /*
4    * Licensed to the Apache Software Foundation (ASF) under one
5    * or more contributor license agreements.  See the NOTICE file
6    * distributed with this work for additional information
7    * regarding copyright ownership.  The ASF licenses this file
8    * to you under the Apache License, Version 2.0 (the
9    * "License"); you may not use this file except in compliance
10   * with the License.  You may obtain a copy of the License at
11   *
12   *  http://www.apache.org/licenses/LICENSE-2.0
13   *
14   * Unless required by applicable law or agreed to in writing,
15   * software distributed under the License is distributed on an
16   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
17   * KIND, either express or implied.  See the License for the
18   * specific language governing permissions and limitations
19   * under the License.
20   */
21  
22  import java.io.File;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.net.InetAddress;
26  import java.net.UnknownHostException;
27  import java.nio.charset.StandardCharsets;
28  import java.nio.file.Files;
29  import java.nio.file.Path;
30  import java.nio.file.Paths;
31  import java.util.Collection;
32  import java.util.Collections;
33  import java.util.Deque;
34  import java.util.Iterator;
35  import java.util.LinkedHashMap;
36  import java.util.LinkedList;
37  import java.util.Map;
38  import java.util.TreeSet;
39  
40  import javax.annotation.PreDestroy;
41  import javax.annotation.Priority;
42  import javax.inject.Named;
43  import javax.inject.Singleton;
44  
45  import org.eclipse.aether.RepositorySystemSession;
46  import org.eclipse.aether.SyncContext;
47  import org.eclipse.aether.artifact.Artifact;
48  import org.eclipse.aether.impl.SyncContextFactory;
49  import org.eclipse.aether.metadata.Metadata;
50  import org.eclipse.aether.util.ChecksumUtils;
51  import org.eclipse.aether.util.ConfigUtils;
52  import org.redisson.Redisson;
53  import org.redisson.api.RLock;
54  import org.redisson.api.RReadWriteLock;
55  import org.redisson.api.RedissonClient;
56  import org.redisson.config.Config;
57  import org.slf4j.Logger;
58  import org.slf4j.LoggerFactory;
59  
60  /**
61   * A singleton factory to create synchronization contexts using Redisson's {@link RReadWriteLock}.
62   * It locks fine-grained with groupId, artifactId and version if required.
63   * <p>
64   * <strong>Note: This component is still considered to be experimental, use with caution!</strong>
65   * <h2>Configuration</h2>
66   * You can configure various aspects of this factory.
67   *
68   * <h3>Redisson Client</h3>
69   * To fully configure the Redisson client, this factory uses the following staggered approach:
70   * <ol>
71   * <li>If the property {@code aether.syncContext.redisson.configFile} is set and the file at that
72   * specific path does exist, load it otherwise an exception is thrown.</li>
73   * <li>If no configuration file path is provided, load default from
74   * <code>${maven.conf}/maven-resolver-redisson.yaml</code>, but ignore if it does not exist.</li>
75   * <li>If no configuration file is available at all, Redisson is configured with a single server pointing
76   * to {@code redis://localhost:6379} with client name {@code maven-resolver}.</li>
77   * </ol>
78   * Please note that an invalid confguration file results in an exception too.
79   *
80   * <h3>Discrimination</h3>
81   * You may freely use a single Redis instance to serve multiple Maven instances, on multiple hosts
82   * with shared or exclusive local repositories. Every sync context instance will generate a unique
83   * discriminator which identifies each host paired with the local repository currently accessed.
84   * The following staggered approach is used:
85   * <ol>
86   * <li>Determine hostname, if not possible use {@code localhost}.</li>
87   * <li>If the property {@code aether.syncContext.redisson.discriminator} is set, use it and skip
88   * the remaining steps.</li>
89   * <li>Concat hostname with the path of the local repository: <code>${hostname}:${maven.repo.local}</code>.</li>
90   * <li>Calculate the SHA-1 digest of this value. If that fails use the static digest of an empty string.</li>
91   * </ol>
92   *
93   * <h2>Key Composition</h2>
94   * Each lock is assigned a unique key in the configured Redis instance which has the following pattern:
95   * <code>maven:resolver:${discriminator}:${artifact|metadata}</code>.
96   * <ul>
97   * <li><code>${artifact}</code> will
98   * always resolve to <code>artifact:${groupId}:${artifactId}:${baseVersion}</code>.</li>
99   * <li><code>${metadata}</code> will resolve to one of <code>metadata:${groupId}:${artifactId}:${version}</code>,
100  * <code>metadata:${groupId}:${artifactId}</code>, <code>metadata:${groupId}</code>,
101  * <code>metadata:</code>.</li>
102  * </ul>
103  */
104 @Named
105 @Priority( Integer.MAX_VALUE )
106 @Singleton
107 public class RedissonSyncContextFactory
108     implements SyncContextFactory
109 {
110 
111     private static final String DEFAULT_CONFIG_FILE_NAME = "maven-resolver-redisson.yaml";
112     private static final String DEFAULT_REDIS_ADDRESS = "redis://localhost:6379";
113     private static final String DEFAULT_CLIENT_NAME = "maven-resolver";
114     private static final String DEFAULT_HOSTNAME = "localhost";
115     private static final String DEFAULT_DISCRIMINATOR_DIGEST = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
116 
117     private static final String CONFIG_PROP_CONFIG_FILE = "aether.syncContext.redisson.configFile";
118 
119     private static final Logger LOGGER = LoggerFactory.getLogger( RedissonSyncContextFactory.class );
120 
121     // We are in a singleton so these should exist only once!
122     private RedissonClient redissonClient;
123     private String hostname;
124 
125     public RedissonSyncContextFactory()
126     {
127         // TODO These two log statements will go away
128         LOGGER.trace( "TCCL: {}", Thread.currentThread().getContextClassLoader() );
129         LOGGER.trace( "CCL: {}", getClass().getClassLoader() );
130         this.redissonClient = createRedissonClient();
131         this.hostname = getHostname();
132     }
133 
134     private RedissonClient createRedissonClient()
135     {
136         Path configFilePath = null;
137 
138         String configFile = ConfigUtils.getString( System.getProperties(), null, CONFIG_PROP_CONFIG_FILE );
139         if ( configFile != null && !configFile.isEmpty() )
140         {
141             configFilePath = Paths.get( configFile );
142             if ( Files.notExists( configFilePath ) )
143             {
144                 throw new IllegalArgumentException( "The specified Redisson config file does not exist: "
145                                                     + configFilePath );
146             }
147         }
148 
149         if ( configFilePath == null )
150         {
151             String mavenConf = ConfigUtils.getString( System.getProperties(), null, "maven.conf" );
152             if ( mavenConf != null && !mavenConf.isEmpty() )
153             {
154                 configFilePath = Paths.get( mavenConf, DEFAULT_CONFIG_FILE_NAME );
155                 if ( Files.notExists( configFilePath ) )
156                 {
157                     configFilePath = null;
158                 }
159             }
160         }
161 
162         Config config = null;
163 
164         if ( configFilePath != null )
165         {
166             LOGGER.trace( "Reading Redisson config file from '{}'", configFilePath );
167             try ( InputStream is = Files.newInputStream( configFilePath ) )
168             {
169                 config = Config.fromYAML( is );
170             }
171             catch ( IOException e )
172             {
173                 throw new IllegalStateException( "Failed to read Redisson config file: " + configFilePath, e );
174             }
175         }
176         else
177         {
178             config = new Config();
179             config.useSingleServer()
180                 .setAddress( DEFAULT_REDIS_ADDRESS )
181                 .setClientName( DEFAULT_CLIENT_NAME );
182         }
183 
184         RedissonClient redissonClient = Redisson.create( config );
185         LOGGER.trace( "Created Redisson client with id '{}'", redissonClient.getId() );
186 
187         return redissonClient;
188     }
189 
190     private String getHostname()
191     {
192         try
193         {
194             return InetAddress.getLocalHost().getHostName();
195         }
196         catch ( UnknownHostException e )
197         {
198             LOGGER.warn( "Failed to get hostname, using '{}'",
199                          DEFAULT_HOSTNAME, e );
200             return DEFAULT_HOSTNAME;
201         }
202     }
203 
204     public SyncContext newInstance( RepositorySystemSession session, boolean shared )
205     {
206         // This log statement will go away
207         LOGGER.trace( "Instance: {}", this );
208         return new RedissonSyncContext( session, hostname, redissonClient, shared );
209     }
210 
211     @PreDestroy
212     public void shutdown()
213     {
214         LOGGER.trace( "Shutting down Redisson client with id '{}'", redissonClient.getId() );
215         redissonClient.shutdown();
216     }
217 
218     static class RedissonSyncContext
219         implements SyncContext
220     {
221 
222         private static final String CONFIG_PROP_DISCRIMINATOR = "aether.syncContext.redisson.discriminator";
223 
224         private static final String KEY_PREFIX = "maven:resolver:";
225 
226         private static final Logger LOGGER = LoggerFactory.getLogger( RedissonSyncContext.class );
227 
228         private final RepositorySystemSession session;
229         private final String hostname;
230         private final RedissonClient redissonClient;
231         private final boolean shared;
232         private final Map<String, RReadWriteLock> locks = new LinkedHashMap<>();
233 
234         private RedissonSyncContext( RepositorySystemSession session, String hostname,
235                 RedissonClient redissonClient, boolean shared )
236         {
237             this.session = session;
238             this.hostname = hostname;
239             this.redissonClient = redissonClient;
240             this.shared = shared;
241         }
242 
243         public void acquire( Collection<? extends Artifact> artifacts,
244                 Collection<? extends Metadata> metadatas )
245         {
246             // Deadlock prevention: https://stackoverflow.com/a/16780988/696632
247             // We must acquire multiple locks always in the same order!
248             Collection<String> keys = new TreeSet<>();
249             if ( artifacts != null )
250             {
251                 for ( Artifact artifact : artifacts )
252                 {
253                     // TODO Should we include extension and classifier too?
254                     String key = "artifact:" + artifact.getGroupId() + ":"
255                             + artifact.getArtifactId() + ":" + artifact.getBaseVersion();
256                     keys.add( key );
257                 }
258             }
259 
260             if ( metadatas != null )
261             {
262                 for ( Metadata metadata : metadatas )
263                 {
264                     StringBuilder key = new StringBuilder( "metadata:" );
265                     if ( !metadata.getGroupId().isEmpty() )
266                     {
267                         key.append( metadata.getGroupId() );
268                         if ( !metadata.getArtifactId().isEmpty() )
269                         {
270                             key.append( ':' ).append( metadata.getArtifactId() );
271                             if ( !metadata.getVersion().isEmpty() )
272                             {
273                                 key.append( ':' ).append( metadata.getVersion() );
274                             }
275                         }
276                     }
277                     keys.add( key.toString() );
278                 }
279             }
280 
281             if ( keys.isEmpty() )
282             {
283                 return;
284             }
285 
286             String discriminator = createDiscriminator();
287             LOGGER.trace( "Using Redis key discriminator '{}' during this session", discriminator );
288 
289             LOGGER.trace( "Need {} {} lock(s) for {}", keys.size(), shared ? "read" : "write", keys );
290             int acquiredLockCount = 0;
291             int reacquiredLockCount = 0;
292             for ( String key : keys )
293             {
294                 RReadWriteLock rwLock = locks.get( key );
295                 if ( rwLock == null )
296                 {
297                     rwLock = redissonClient
298                             .getReadWriteLock( KEY_PREFIX + discriminator + ":" + key );
299                     locks.put( key, rwLock );
300                     acquiredLockCount++;
301                 }
302                 else
303                 {
304                     reacquiredLockCount++;
305                 }
306 
307                 RLock actualLock = shared ? rwLock.readLock() : rwLock.writeLock();
308                 // Avoid #getHoldCount() and #isLocked() roundtrips when we are not logging
309                 if ( LOGGER.isTraceEnabled() )
310                 {
311                     LOGGER.trace( "Acquiring {} lock for '{}' (currently held: {}, already locked: {})",
312                                   shared ? "read" : "write", key, actualLock.getHoldCount(),
313                                   actualLock.isLocked() );
314                 }
315                 // If this still produces a deadlock we might need to switch to #tryLock() with n attempts
316                 actualLock.lock();
317             }
318             LOGGER.trace( "Total new locks acquired: {}, total existing locks reacquired: {}",
319                           acquiredLockCount, reacquiredLockCount );
320         }
321 
322         private String createDiscriminator()
323         {
324             String discriminator = ConfigUtils.getString( session, null, CONFIG_PROP_DISCRIMINATOR );
325 
326             if ( discriminator == null || discriminator.isEmpty() )
327             {
328 
329                 File basedir = session.getLocalRepository().getBasedir();
330                 discriminator = hostname + ":" + basedir;
331                 try
332                 {
333                     Map<String, Object> checksums = ChecksumUtils.calc(
334                             discriminator.toString().getBytes( StandardCharsets.UTF_8 ),
335                             Collections.singletonList( "SHA-1" ) );
336                     Object checksum = checksums.get( "SHA-1" );
337 
338                     if ( checksum instanceof Exception )
339                     {
340                         throw (Exception) checksum;
341                     }
342 
343                     return String.valueOf( checksum );
344                 }
345                 catch ( Exception e )
346                 {
347                     // TODO Should this be warn?
348                     LOGGER.trace( "Failed to calculate discriminator digest, using '{}'",
349                                   DEFAULT_DISCRIMINATOR_DIGEST, e );
350                     return DEFAULT_DISCRIMINATOR_DIGEST;
351                 }
352             }
353 
354             return discriminator;
355         }
356 
357         public void close()
358         {
359             if ( locks.isEmpty() )
360             {
361                 return;
362             }
363 
364             // Release locks in reverse insertion order
365             Deque<String> keys = new LinkedList<>( locks.keySet() );
366             Iterator<String> keysIter = keys.descendingIterator();
367             while ( keysIter.hasNext() )
368             {
369                 String key = keysIter.next();
370                 RReadWriteLock rwLock = locks.get( key );
371                 RLock actualLock = shared ? rwLock.readLock() : rwLock.writeLock();
372                 while ( actualLock.getHoldCount() > 0 )
373                 {
374                     // Avoid #getHoldCount() roundtrips when we are not logging
375                     if ( LOGGER.isTraceEnabled() )
376                     {
377                         LOGGER.trace( "Releasing {} lock for '{}' (currently held: {})",
378                                       shared ? "read" : "write", key, actualLock.getHoldCount() );
379                     }
380                     actualLock.unlock();
381                 }
382             }
383             // TODO Should we count reentrant ones too?
384             LOGGER.trace( "Total locks released: {}", locks.size() );
385             locks.clear();
386         }
387 
388     }
389 
390 }