Implementing graceful shutdown, fattest daemon elections and some minor refactorings in daemon and client behavior

This commit is contained in:
Ilya Chernikov
2015-11-17 13:12:54 +01:00
parent 2612ce56b4
commit 85b487cd84
5 changed files with 210 additions and 80 deletions
@@ -57,9 +57,9 @@ public object KotlinCompilerClient {
val flagFile = System.getProperty(COMPILE_DAEMON_CLIENT_ALIVE_PATH_PROPERTY)
?.let { it.trimQuotes() }
?.ifOrNull { !isBlank() }
?.ifOrNull { !it.isBlank() }
?.let { File(it) }
?.ifOrNull { exists() }
?.ifOrNull { it.exists() }
?: newFlagFile()
return connectToCompileService(compilerId, flagFile, daemonJVMOptions, daemonOptions, reportingTargets, autostart)
}
@@ -254,14 +254,17 @@ public object KotlinCompilerClient {
private fun tryFindSuitableDaemonOrNewOpts(registryDir: File, compilerId: CompilerId, daemonJVMOptions: DaemonJVMOptions, report: (DaemonReportCategory, String) -> Unit): Pair<CompileService?, DaemonJVMOptions> {
val aliveWithOpts = walkDaemons(registryDir, compilerId, report)
val aliveWithOpts = walkDaemons(registryDir, compilerId, report = report)
.map { Pair(it, it.getDaemonJVMOptions()) }
.filter { it.second.isGood }
// TODO: consider to sort the found daemons by memory settings and take the largest (rather that the first found), but carefully analyze possible situations first
.sortedWith(compareBy(DaemonJVMOptionsMemoryComparator().reversed(), { it.second.get() }))
val opts = daemonJVMOptions.copy()
return aliveWithOpts.firstOrNull { daemonJVMOptions memorywiseFitsInto it.second.get() }
?.let { Pair(it.first, opts.updateMemoryUpperBounds(it.second.get())) }
?: Pair(null, aliveWithOpts.fold(daemonJVMOptions, { opts, d -> opts.updateMemoryUpperBounds(d.second.get()) }))
// if required options fit into fattest running daemon - return the daemon and required options with memory params set to actual ones in the daemon
return aliveWithOpts.firstOrNull()?.ifOrNull { daemonJVMOptions memorywiseFitsInto it.second.get() }?.let {
Pair(it.first, opts.updateMemoryUpperBounds(it.second.get()))
}
// else combine all options from running daemon to get fattest option for a new daemon to run
?: Pair(null, aliveWithOpts.fold(daemonJVMOptions, { opts, d -> opts.updateMemoryUpperBounds(d.second.get()) }))
}
@@ -351,4 +354,5 @@ internal fun isProcessAlive(process: Process) =
true
}
internal fun<T> T.ifOrNull(pred: T.() -> Boolean): T? = if (this.pred()) this else null
internal inline fun<T> T.ifOrNull(pred: (T) -> Boolean): T? = if (pred(this)) this else null
@@ -40,11 +40,15 @@ fun String.extractPortFromRunFilename(digest: String): Int =
?: 0
fun walkDaemons(registryDir: File, compilerId: CompilerId, report: (DaemonReportCategory, String) -> Unit): Sequence<CompileService> {
fun walkDaemons(registryDir: File,
compilerId: CompilerId,
filter: (File, Int) -> Boolean = { f,p -> true },
report: (DaemonReportCategory, String) -> Unit = { cat, msg -> ; }
): Sequence<CompileService> {
val classPathDigest = compilerId.compilerClasspath.map { File(it).absolutePath }.distinctStringsDigest().toHexString()
return registryDir.walk()
.map { Pair(it, it.name.extractPortFromRunFilename(classPathDigest)) }
.filter { it.second != 0 }
.filter { it.second != 0 && filter(it.first, it.second) }
.map {
assert(it.second > 0 && it.second < MAX_PORT_NUMBER)
report(DaemonReportCategory.DEBUG, "found daemon on port ${it.second}, trying to connect")
@@ -70,14 +70,20 @@ public interface CompileService : Remote {
// TODO: consider adding another client alive checking mechanism, e.g. socket/port
@Throws(RemoteException::class)
public fun getClients(): CallResult<List<String>>
@Throws(RemoteException::class)
public fun leaseCompileSession(aliveFlagPath: String?): CallResult<Int>
@Throws(RemoteException::class)
public fun releaseCompileSession(sessionId: Int): Unit
public fun releaseCompileSession(sessionId: Int): CallResult<Nothing>
@Throws(RemoteException::class)
public fun shutdown(): Unit
public fun shutdown(): CallResult<Nothing>
@Throws(RemoteException::class)
public fun scheduleShutdown(graceful: Boolean): CallResult<Boolean>
// TODO: consider adding async version of shutdown and release
@@ -21,6 +21,7 @@ import java.io.File
import java.io.Serializable
import java.lang.management.ManagementFactory
import java.security.MessageDigest
import java.util.*
import kotlin.reflect.KMutableProperty1
import kotlin.text.RegexOption
@@ -224,6 +225,10 @@ public data class DaemonOptions(
BoolPropMapper(this, DaemonOptions::reportPerf))
}
// TODO: consider implementing generic approach to it or may be replace getters with ones returning default if necessary
val DaemonOptions.runFilesPathOrDefault: String
get() = if (runFilesPath.isBlank()) COMPILE_DAEMON_DEFAULT_RUN_DIR_PATH else runFilesPath
fun Iterable<String>.distinctStringsDigest(): ByteArray =
MessageDigest.getInstance(CLASSPATH_ID_DIGEST)
@@ -324,17 +329,31 @@ fun String.memToBytes(): Long? =
}
private fun daemonJVMOptionsMemoryProps() =
private val daemonJVMOptionsMemoryProps: List<KMutableProperty1<DaemonJVMOptions, String>> by lazy {
listOf(DaemonJVMOptions::maxMemory, DaemonJVMOptions::maxPermSize, DaemonJVMOptions::reservedCodeCacheSize)
}
infix fun DaemonJVMOptions.memorywiseFitsInto(other: DaemonJVMOptions): Boolean =
daemonJVMOptionsMemoryProps()
daemonJVMOptionsMemoryProps
.all { (it.get(this).memToBytes() ?: 0) <= (it.get(other).memToBytes() ?: 0) }
fun compareDaemonJVMOptionsMemory(left: DaemonJVMOptions, right: DaemonJVMOptions): Int {
val props = daemonJVMOptionsMemoryProps.map { Pair(it.get(left).memToBytes() ?: 0, it.get(right).memToBytes() ?: 0) }
return when {
props.all { it.first == it.second } -> 0
props.all { it.first <= it.second } -> -1
props.all { it.first >= it.second } -> 1
else -> 0
}
}
class DaemonJVMOptionsMemoryComparator : Comparator<DaemonJVMOptions> {
override fun compare(left: DaemonJVMOptions, right: DaemonJVMOptions): Int = compareDaemonJVMOptionsMemory(left, right)
}
fun DaemonJVMOptions.updateMemoryUpperBounds(other: DaemonJVMOptions): DaemonJVMOptions {
daemonJVMOptionsMemoryProps()
daemonJVMOptionsMemoryProps
.forEach { if ((it.get(this).memToBytes() ?: 0) < (it.get(other).memToBytes() ?: 0)) it.set(this, it.get(other)) }
return this
}
@@ -67,7 +67,7 @@ class CompileServiceImpl(
val compilerId: CompilerId,
val daemonOptions: DaemonOptions,
val daemonJVMOptions: DaemonJVMOptions,
port: Int,
val port: Int,
val timer: Timer,
val onShutdown: () -> Unit
) : CompileService {
@@ -79,16 +79,27 @@ class CompileServiceImpl(
val isAlive: Boolean get() = aliveFlagPath?.let { File(it).exists() } ?: true // assuming that if no file was given, the client is alive
}
private val clientProxies: MutableSet<ClientOrSessionProxy> = hashSetOf()
private val sessions: MutableMap<Int, ClientOrSessionProxy> = hashMapOf()
private val sessionsIdCounter = AtomicInteger(0)
private val compilationsCounter = AtomicInteger(0)
private val internalRng = Random()
private val classpathWatcher = LazyClasspathWatcher(compilerId.compilerClasspath)
private val compilationsCounter = AtomicInteger(0)
private val shutdownQueued = AtomicBoolean(false)
enum class Aliveness(val value: Int) {
// ordering of values is used in state comparison
Alive(2), LastSession(1), Dying(0)
}
// TODO: encapsulate operations on state here
private val state = object {
val clientProxies: MutableSet<ClientOrSessionProxy> = hashSetOf()
val sessions: MutableMap<Int, ClientOrSessionProxy> = hashMapOf()
val delayedShutdownQueued = AtomicBoolean(false)
var alive = AtomicInteger(Aliveness.Alive.value)
}
@Volatile private var _lastUsedSeconds = nowSeconds()
public val lastUsedSeconds: Long get() = if (rwlock.isWriteLocked || rwlock.readLockCount - rwlock.readHoldCount > 0) nowSeconds() else _lastUsedSeconds
@@ -96,12 +107,11 @@ class CompileServiceImpl(
val log by lazy { Logger.getLogger("compiler") }
private val rwlock = ReentrantReadWriteLock()
private var alive = false
private var runFile: File
init {
val runFileDir = File(if (daemonOptions.runFilesPath.isBlank()) COMPILE_DAEMON_DEFAULT_RUN_DIR_PATH else daemonOptions.runFilesPath)
val runFileDir = File(daemonOptions.runFilesPathOrDefault)
runFileDir.mkdirs()
runFile = File(runFileDir,
makeRunFilenameString(timestamp = "%tFT%<tH-%<tM-%<tS.%<tLZ".format(Calendar.getInstance(TimeZone.getTimeZone("Z"))),
@@ -122,21 +132,27 @@ class CompileServiceImpl(
override fun getDaemonJVMOptions(): CompileService.CallResult<DaemonJVMOptions> = ifAlive { daemonJVMOptions }
override fun registerClient(aliveFlagPath: String?): CompileService.CallResult<Nothing> = ifAlive_Nothing {
synchronized(clientProxies) {
clientProxies.add(ClientOrSessionProxy(aliveFlagPath))
synchronized(state.clientProxies) {
state.clientProxies.add(ClientOrSessionProxy(aliveFlagPath))
}
}
override fun getClients(): CompileService.CallResult<List<String>> = ifAlive {
synchronized(state.clientProxies) {
state.clientProxies.map { it.aliveFlagPath }.filterNotNull()
}
}
// TODO: consider tying a session to a client and use this info to cleanup
override fun leaseCompileSession(aliveFlagPath: String?): CompileService.CallResult<Int> = ifAlive {
override fun leaseCompileSession(aliveFlagPath: String?): CompileService.CallResult<Int> = ifAlive(minAliveness = Aliveness.Alive) {
// fighting hypothetical integer wrapping
var newId = sessionsIdCounter.incrementAndGet()
val session = ClientOrSessionProxy(aliveFlagPath)
for (attempt in 1..100) {
if (newId != CompileService.NO_SESSION) {
synchronized(sessions) {
if (!sessions.containsKey(newId)) {
sessions.put(newId, session)
synchronized(state.sessions) {
if (!state.sessions.containsKey(newId)) {
state.sessions.put(newId, session)
return@ifAlive newId
}
}
@@ -147,15 +163,17 @@ class CompileServiceImpl(
throw Exception("Invalid state or algorithm error")
}
override fun releaseCompileSession(sessionId: Int) {
synchronized(sessions) {
sessions.remove(sessionId)
override fun releaseCompileSession(sessionId: Int) = ifAlive_Nothing(minAliveness = Aliveness.LastSession) {
synchronized(state.sessions) {
state.sessions.remove(sessionId)
// TODO: some cleanup goes here
if (sessions.isEmpty()) {
if (state.sessions.isEmpty()) {
// TODO: and some goes here
}
}
periodicAndAfterSessionCheck()
timer.schedule(0) {
periodicAndAfterSessionCheck()
}
}
override fun checkCompilerId(expectedCompilerId: CompilerId): Boolean =
@@ -165,14 +183,25 @@ class CompileServiceImpl(
override fun getUsedMemory(): CompileService.CallResult<Long> = ifAlive { usedMemory(withGC = true) }
override fun shutdown() {
ifAliveExclusive(ignoreCompilerChanged = true) {
log.info("Shutdown started")
alive = false
UnicastRemoteObject.unexportObject(this, true)
log.info("Shutdown complete")
onShutdown()
override fun shutdown(): CompileService.CallResult<Nothing> = ifAliveExclusive_Nothing(minAliveness = Aliveness.LastSession, ignoreCompilerChanged = true) {
shutdownImpl()
}
override fun scheduleShutdown(graceful: Boolean): CompileService.CallResult<Boolean> = ifAlive(minAliveness = Aliveness.Alive) {
if (!graceful || state.alive.compareAndSet(Aliveness.Alive.value, Aliveness.LastSession.value)) {
timer.schedule(0) {
ifAliveExclusive(minAliveness = Aliveness.LastSession, ignoreCompilerChanged = true) {
if (!graceful || state.sessions.isEmpty()) {
shutdownImpl()
}
else {
log.info("Some sessions are active, waiting for them to finish")
}
}
}
true
}
else false
}
override fun remoteCompile(sessionId: Int,
@@ -234,8 +263,10 @@ class CompileServiceImpl(
val stub = UnicastRemoteObject.exportObject(this, port, LoopbackNetworkInterface.clientLoopbackSocketFactory, LoopbackNetworkInterface.serverLoopbackSocketFactory) as CompileService
registry.rebind (COMPILER_SERVICE_RMI_NAME, stub);
alive = true
timer.schedule(0) {
initiateElections()
}
timer.schedule(delay = DAEMON_PERIODIC_CHECK_INTERVAL_MS, period = DAEMON_PERIODIC_CHECK_INTERVAL_MS) {
try {
periodicAndAfterSessionCheck()
@@ -248,45 +279,99 @@ class CompileServiceImpl(
}
}
private fun periodicAndAfterSessionCheck() {
// 1. check if unused for a timeout - shutdown
if (shutdownCondition({ daemonOptions.autoshutdownUnusedSeconds != COMPILE_DAEMON_TIMEOUT_INFINITE_S && compilationsCounter.get() == 0 && nowSeconds() - lastUsedSeconds > daemonOptions.autoshutdownUnusedSeconds },
"Unused timeout exceeded ${daemonOptions.autoshutdownUnusedSeconds}s, shutting down")) {
shutdown()
}
else {
synchronized(sessions) {
// 2. check if any session hanged - clean
// making copy of the list before calling release
sessions.filterValues { !it.isAlive }.keys.toArrayList()
}.forEach { releaseCompileSession(it) }
ifAlive_Nothing(minAliveness = Aliveness.LastSession) {
// 3. clean dead clients, then check if any left - conditional shutdown (with small delay)
synchronized(clientProxies) { clientProxies.removeAll( clientProxies.filter{ !it.isAlive }) }
if (clientProxies.none() && compilationsCounter.get() > 0 && !shutdownQueued.get()) {
log.info("No more clients left, delayed shutdown in ${daemonOptions.shutdownDelayMilliseconds}ms")
shutdownWithDelay()
}
// 4. check idle timeout - shutdown
if (shutdownCondition({ daemonOptions.autoshutdownIdleSeconds != COMPILE_DAEMON_TIMEOUT_INFINITE_S && nowSeconds() - lastUsedSeconds > daemonOptions.autoshutdownIdleSeconds },
"Idle timeout exceeded ${daemonOptions.autoshutdownIdleSeconds}s, shutting down") ||
// 5. discovery file removed - shutdown
shutdownCondition({ !runFile.exists() }, "Run file removed, shutting down") ||
// 6. compiler changed (seldom check) - shutdown
// TODO: could be too expensive anyway, consider removing this check
shutdownCondition({ classpathWatcher.isChanged }, "Compiler changed"))
{
// 1. check if unused for a timeout - shutdown
if (shutdownCondition({ daemonOptions.autoshutdownUnusedSeconds != COMPILE_DAEMON_TIMEOUT_INFINITE_S && compilationsCounter.get() == 0 && nowSeconds() - lastUsedSeconds > daemonOptions.autoshutdownUnusedSeconds },
"Unused timeout exceeded ${daemonOptions.autoshutdownUnusedSeconds}s, shutting down")) {
shutdown()
}
else {
synchronized(state.sessions) {
// 2. check if any session hanged - clean
// making copy of the list before calling release
state.sessions.filterValues { !it.isAlive }.keys.toArrayList()
}.forEach { releaseCompileSession(it) }
// 3. check if in graceful shutdown state and all sessions are closed
if (shutdownCondition({state.alive.get() == Aliveness.LastSession.value && state.sessions.none()}, "All sessions finished, shutting down")) {
shutdown()
}
// 4. clean dead clients, then check if any left - conditional shutdown (with small delay)
synchronized(state.clientProxies) { state.clientProxies.removeAll(state.clientProxies.filter { !it.isAlive }) }
if (state.clientProxies.none() && compilationsCounter.get() > 0 && !state.delayedShutdownQueued.get()) {
log.info("No more clients left, delayed shutdown in ${daemonOptions.shutdownDelayMilliseconds}ms")
shutdownWithDelay()
}
// 5. check idle timeout - shutdown
if (shutdownCondition({ daemonOptions.autoshutdownIdleSeconds != COMPILE_DAEMON_TIMEOUT_INFINITE_S && nowSeconds() - lastUsedSeconds > daemonOptions.autoshutdownIdleSeconds },
"Idle timeout exceeded ${daemonOptions.autoshutdownIdleSeconds}s, shutting down") ||
// 6. discovery file removed - shutdown
shutdownCondition({ !runFile.exists() }, "Run file removed, shutting down") ||
// 7. compiler changed (seldom check) - shutdown
// TODO: could be too expensive anyway, consider removing this check
shutdownCondition({ classpathWatcher.isChanged }, "Compiler changed")) {
shutdown()
}
}
}
}
private fun initiateElections() {
ifAlive_Nothing {
val aliveWithOpts = walkDaemons(File(daemonOptions.runFilesPathOrDefault), compilerId, filter = { f, p -> p != port }, report = { lvl, msg -> log.info(msg) })
.map { Pair(it, it.getDaemonJVMOptions()) }
.filter { it.second.isGood }
.sortedWith(compareBy(DaemonJVMOptionsMemoryComparator().reversed(), { it.second.get() }))
if (aliveWithOpts.any()) {
val fattestOpts = aliveWithOpts.first().second.get()
// second part of the condition means that we prefer other daemon if is "equal" to the current one
if (fattestOpts memorywiseFitsInto daemonJVMOptions && !(daemonJVMOptions memorywiseFitsInto fattestOpts)) {
// all others are smaller that me, take overs' clients and shut them down
aliveWithOpts.forEach {
it.first.getClients().ifOrNull { it.isGood }?.let {
it.get().forEach { registerClient(it) }
}
it.first.scheduleShutdown(true)
}
}
else if (daemonJVMOptions memorywiseFitsInto fattestOpts) {
// there is at least one bigger, handover my clients to it and shutdown
scheduleShutdown(true)
aliveWithOpts.first().first.let { fattest ->
getClients().ifOrNull { it.isGood }?.let {
it.get().forEach { fattest.registerClient(it) }
}
}
}
// else - do nothing, all daemons are staying
// TODO: implement some behaviour here, e.g.:
// - shutdown/takeover smaller daemosn
// - run (or better persuade client to run) a bigger daemon (in fact may be even simple shutdown will do, because of client's daemon choosing logic)
}
}
}
private fun shutdownImpl() {
log.info("Shutdown started")
state.alive.set(Aliveness.Dying.value)
UnicastRemoteObject.unexportObject(this, true)
log.info("Shutdown complete")
onShutdown()
}
private fun shutdownWithDelay() {
shutdownQueued.set(true)
state.delayedShutdownQueued.set(true)
val currentCompilationsCount = compilationsCounter.get()
timer.schedule(daemonOptions.shutdownDelayMilliseconds) {
shutdownQueued.set(false)
state.delayedShutdownQueued.set(false)
if (currentCompilationsCount == compilationsCounter.get()) {
log.fine("Execute delayed shutdown")
shutdown()
@@ -388,28 +473,37 @@ class CompileServiceImpl(
}
}
private fun<R> ifAlive(ignoreCompilerChanged: Boolean = false, body: () -> R): CompileService.CallResult<R> = rwlock.read {
ifAliveChecksImpl(ignoreCompilerChanged) { CompileService.CallResult.Good(body()) }
private fun<R> ifAlive(minAliveness: Aliveness = Aliveness.Alive, ignoreCompilerChanged: Boolean = false, body: () -> R): CompileService.CallResult<R> = rwlock.read {
ifAliveChecksImpl(minAliveness, ignoreCompilerChanged) { CompileService.CallResult.Good(body()) }
}
// TODO: find how to implement it without using unique name for this variant; making name deliberately ugly meanwhile
private fun ifAlive_Nothing(ignoreCompilerChanged: Boolean = false, body: () -> Unit): CompileService.CallResult<Nothing> = rwlock.read {
ifAliveChecksImpl(ignoreCompilerChanged) {
private fun ifAlive_Nothing(minAliveness: Aliveness = Aliveness.Alive, ignoreCompilerChanged: Boolean = false, body: () -> Unit): CompileService.CallResult<Nothing> = rwlock.read {
ifAliveChecksImpl(minAliveness, ignoreCompilerChanged) {
body()
CompileService.CallResult.Ok()
}
}
private fun<R> ifAliveExclusive(ignoreCompilerChanged: Boolean = false, body: () -> R): CompileService.CallResult<R> = rwlock.write {
ifAliveChecksImpl(ignoreCompilerChanged) { CompileService.CallResult.Good(body()) }
private fun<R> ifAliveExclusive(minAliveness: Aliveness = Aliveness.Alive, ignoreCompilerChanged: Boolean = false, body: () -> R): CompileService.CallResult<R> = rwlock.write {
ifAliveChecksImpl(minAliveness, ignoreCompilerChanged) { CompileService.CallResult.Good(body()) }
}
inline private fun<R> ifAliveChecksImpl(ignoreCompilerChanged: Boolean = false, body: () -> CompileService.CallResult<R>): CompileService.CallResult<R> =
// see comment to ifAliveNothing
private fun<R> ifAliveExclusive_Nothing(minAliveness: Aliveness = Aliveness.Alive, ignoreCompilerChanged: Boolean = false, body: () -> Unit): CompileService.CallResult<R> = rwlock.write {
ifAliveChecksImpl(minAliveness, ignoreCompilerChanged) {
body()
CompileService.CallResult.Ok()
}
}
inline private fun<R> ifAliveChecksImpl(minAliveness: Aliveness = Aliveness.Alive, ignoreCompilerChanged: Boolean = false, body: () -> CompileService.CallResult<R>): CompileService.CallResult<R> =
when {
!alive -> CompileService.CallResult.Dying()
state.alive.get() < minAliveness.value -> CompileService.CallResult.Dying()
!ignoreCompilerChanged && classpathWatcher.isChanged -> {
log.info("Compiler changed, scheduling shutdown")
timer.schedule(1) { shutdown() }
timer.schedule(0) { shutdown() }
CompileService.CallResult.Dying()
}
else -> {
@@ -424,3 +518,6 @@ class CompileServiceImpl(
}
}
internal inline fun<T> T.ifOrNull(pred: (T) -> Boolean): T? = if (pred(this)) this else null