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}