001package org.eclipse.aether.synccontext;
002
003/*
004 * Licensed to the Apache Software Foundation (ASF) under one
005 * or more contributor license agreements.  See the NOTICE file
006 * distributed with this work for additional information
007 * regarding copyright ownership.  The ASF licenses this file
008 * to you under the Apache License, Version 2.0 (the
009 * "License"); you may not use this file except in compliance
010 * with the License.  You may obtain a copy of the License at
011 *
012 *  http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing,
015 * software distributed under the License is distributed on an
016 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
017 * KIND, either express or implied.  See the License for the
018 * specific language governing permissions and limitations
019 * under the License.
020 */
021
022import java.io.File;
023import java.io.IOException;
024import java.io.InputStream;
025import java.net.InetAddress;
026import java.net.UnknownHostException;
027import java.nio.charset.StandardCharsets;
028import java.nio.file.Files;
029import java.nio.file.Path;
030import java.nio.file.Paths;
031import java.util.Collection;
032import java.util.Collections;
033import java.util.Deque;
034import java.util.Iterator;
035import java.util.LinkedHashMap;
036import java.util.LinkedList;
037import java.util.Map;
038import java.util.TreeSet;
039
040import javax.annotation.PreDestroy;
041import javax.annotation.Priority;
042import javax.inject.Named;
043import javax.inject.Singleton;
044
045import org.eclipse.aether.RepositorySystemSession;
046import org.eclipse.aether.SyncContext;
047import org.eclipse.aether.artifact.Artifact;
048import org.eclipse.aether.impl.SyncContextFactory;
049import org.eclipse.aether.metadata.Metadata;
050import org.eclipse.aether.util.ChecksumUtils;
051import org.eclipse.aether.util.ConfigUtils;
052import org.redisson.Redisson;
053import org.redisson.api.RLock;
054import org.redisson.api.RReadWriteLock;
055import org.redisson.api.RedissonClient;
056import org.redisson.config.Config;
057import org.slf4j.Logger;
058import org.slf4j.LoggerFactory;
059
060/**
061 * A singleton factory to create synchronization contexts using Redisson's {@link RReadWriteLock}.
062 * It locks fine-grained with groupId, artifactId and version if required.
063 * <p>
064 * <strong>Note: This component is still considered to be experimental, use with caution!</strong>
065 * <h2>Configuration</h2>
066 * You can configure various aspects of this factory.
067 *
068 * <h3>Redisson Client</h3>
069 * To fully configure the Redisson client, this factory uses the following staggered approach:
070 * <ol>
071 * <li>If the property {@code aether.syncContext.redisson.configFile} is set and the file at that
072 * specific path does exist, load it otherwise an exception is thrown.</li>
073 * <li>If no configuration file path is provided, load default from
074 * <code>${maven.conf}/maven-resolver-redisson.yaml</code>, but ignore if it does not exist.</li>
075 * <li>If no configuration file is available at all, Redisson is configured with a single server pointing
076 * to {@code redis://localhost:6379} with client name {@code maven-resolver}.</li>
077 * </ol>
078 * Please note that an invalid confguration file results in an exception too.
079 *
080 * <h3>Discrimination</h3>
081 * You may freely use a single Redis instance to serve multiple Maven instances, on multiple hosts
082 * with shared or exclusive local repositories. Every sync context instance will generate a unique
083 * discriminator which identifies each host paired with the local repository currently accessed.
084 * The following staggered approach is used:
085 * <ol>
086 * <li>Determine hostname, if not possible use {@code localhost}.</li>
087 * <li>If the property {@code aether.syncContext.redisson.discriminator} is set, use it and skip
088 * the remaining steps.</li>
089 * <li>Concat hostname with the path of the local repository: <code>${hostname}:${maven.repo.local}</code>.</li>
090 * <li>Calculate the SHA-1 digest of this value. If that fails use the static digest of an empty string.</li>
091 * </ol>
092 *
093 * <h2>Key Composition</h2>
094 * Each lock is assigned a unique key in the configured Redis instance which has the following pattern:
095 * <code>maven:resolver:${discriminator}:${artifact|metadata}</code>.
096 * <ul>
097 * <li><code>${artifact}</code> will
098 * always resolve to <code>artifact:${groupId}:${artifactId}:${baseVersion}</code>.</li>
099 * <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
107public 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}