Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,17 @@ Notes and current limitations:
warning; point `--gitEngine=subprocess` at such workspaces to skip the in-process attempt (a
partial clone in particular needs the subprocess engine to *serve* queries, since JGit cannot
lazily fetch the missing blobs a checkout needs).
* Hashes are cached on local disk via `--cacheDir` and survive restarts. The cache layer is
pluggable behind a byte-oriented interface so a remote backend (e.g. S3) can be added without
touching callers.
* Hashes are cached on local disk via `--cacheDir` and survive restarts. Left unbounded the cache
grows by one entry per distinct commit SHA queried, so a long-running server can bound it with any
combination of `--cacheMaxAge` (expire entries not read or written within a window, e.g. `7d`),
`--cacheMaxEntries`, and `--cacheMaxSize` (e.g. `10GB`, `500MB`, or a bare byte count). A background
sweeper enforces the limits once at startup and then every `--cachePruneInterval` (default `1h`),
evicting least-recently-used entries first — a cache hit refreshes an entry's recency, so revisions
under active query are not expired out from under live traffic. With no `--cacheMax*` flag set the
cache is never pruned (the previous behavior). The cache layer is pluggable behind a byte-oriented
interface so a remote backend (e.g. S3) can be added without touching callers; such a backend
manages its own retention (e.g. a bucket lifecycle policy), and the in-process `--cacheMax*` flags
do not apply to it.
* Query-affecting flags (`--useCquery`, `--fineGrainedHashExternalRepos`, etc.) mirror
`generate-hashes`, and are folded into the cache key so a server started with different flags never
serves another configuration's cached hashes.
Expand Down Expand Up @@ -461,6 +469,10 @@ Command-line utility to analyze the state of the bazel build graph
Usage: bazel-diff serve [-hkvV] [--[no-]excludeExternalTargets]
[--no-initial-fetch] [--[no-]trackDeps] [--[no-]
useCquery] [-b=<bazelPath>] --cacheDir=<cacheDir>
[--cacheMaxAge=<cacheMaxAge>]
[--cacheMaxEntries=<cacheMaxEntries>]
[--cacheMaxSize=<cacheMaxSize>]
[--cachePruneInterval=<cachePruneInterval>]
[--cqueryExpression=<cqueryExpression>]
[--excludeTargetsQuery=<excludeTargetsQuery>]
[--fineGrainedHashExternalReposFile=<fineGrainedHashExte
Expand All @@ -482,6 +494,26 @@ targets between two git revisions, caching generated hashes per commit SHA.
binary available in PATH will be used.
--cacheDir=<cacheDir> Directory where generated hashes are cached per
commit SHA. Persists across restarts.
--cacheMaxAge=<cacheMaxAge>
Evict cached hashes not read or written within this
window, so the cache does not grow without bound
over time. Duration like 7d, 36h, 90m (units
d/h/m/s). Unset means no age limit. Enforced by a
background sweeper (see --cachePruneInterval).
--cacheMaxEntries=<cacheMaxEntries>
Keep at most this many cached commit-SHA entries,
evicting the least-recently-used first. Unset
means no count limit.
--cacheMaxSize=<cacheMaxSize>
Keep the cache's total on-disk size at or below
this, evicting the least-recently-used entries
first. Size like 10GB, 500MB, or a bare byte
count (base 1024). Unset means no size limit.
--cachePruneInterval=<cachePruneInterval>
How often the background sweeper enforces the
--cacheMax* limits. Duration like 1h, 30m.
Defaults to 1h. No effect unless a --cacheMax*
limit is set.
-co, --bazelCommandOptions=<bazelCommandOptions>
Additional space separated Bazel command options
used when invoking `bazel query`
Expand Down
18 changes: 18 additions & 0 deletions cli/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ kt_jvm_test(
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "DurationConverterTest",
test_class = "com.bazel_diff.cli.converter.DurationConverterTest",
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "ByteSizeConverterTest",
test_class = "com.bazel_diff.cli.converter.ByteSizeConverterTest",
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "DeserialiseHashesInteractorTest",
test_class = "com.bazel_diff.interactor.DeserialiseHashesInteractorTest",
Expand Down Expand Up @@ -243,6 +255,12 @@ kt_jvm_test(
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "CachePrunerTest",
test_class = "com.bazel_diff.server.CachePrunerTest",
runtime_deps = [":cli-test-lib"],
)

kt_jvm_test(
name = "GitClientTest",
test_class = "com.bazel_diff.server.GitClientTest",
Expand Down
81 changes: 80 additions & 1 deletion cli/src/main/kotlin/com/bazel_diff/cli/ServeCommand.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.bazel_diff.cli

import com.bazel_diff.cli.converter.ByteSizeConverter
import com.bazel_diff.cli.converter.CommaSeparatedValueConverter
import com.bazel_diff.cli.converter.DurationConverter
import com.bazel_diff.cli.converter.NormalisingPathConverter
import com.bazel_diff.cli.converter.OptionsConverter
import com.bazel_diff.di.hasherModule
Expand All @@ -9,6 +11,8 @@ import com.bazel_diff.di.serialisationModule
import com.bazel_diff.extensions.toHexString
import com.bazel_diff.hash.sha256
import com.bazel_diff.server.BazelDiffServer
import com.bazel_diff.server.CachePruneLimits
import com.bazel_diff.server.CachePruner
import com.bazel_diff.server.GitClient
import com.bazel_diff.server.HashCacheStorage
import com.bazel_diff.server.HashProvider
Expand All @@ -17,8 +21,10 @@ import com.bazel_diff.server.ImpactedTargetsService
import com.bazel_diff.server.JGitClient
import com.bazel_diff.server.LocalDiskHashCacheStorage
import com.bazel_diff.server.ProcessGitClient
import com.bazel_diff.server.PrunableHashCacheStorage
import java.io.File
import java.nio.file.Path
import java.time.Duration
import java.util.concurrent.Callable
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicBoolean
Expand Down Expand Up @@ -107,6 +113,44 @@ class ServeCommand : Callable<Int> {
converter = [NormalisingPathConverter::class])
lateinit var cacheDir: Path

@CommandLine.Option(
names = ["--cacheMaxAge"],
description =
[
"Evict cached hashes not read or written within this window, so the cache does not " +
"grow without bound over time. Duration like 7d, 36h, 90m (units d/h/m/s). Unset " +
"means no age limit. Enforced by a background sweeper (see --cachePruneInterval)."],
converter = [DurationConverter::class])
var cacheMaxAge: Duration? = null

@CommandLine.Option(
names = ["--cacheMaxEntries"],
description =
[
"Keep at most this many cached commit-SHA entries, evicting the least-recently-used " +
"first. Unset means no count limit."])
var cacheMaxEntries: Int? = null

@CommandLine.Option(
names = ["--cacheMaxSize"],
description =
[
"Keep the cache's total on-disk size at or below this, evicting the " +
"least-recently-used entries first. Size like 10GB, 500MB, or a bare byte count " +
"(base 1024). Unset means no size limit."],
converter = [ByteSizeConverter::class])
var cacheMaxSize: Long? = null

@CommandLine.Option(
names = ["--cachePruneInterval"],
description =
[
"How often the background sweeper enforces the --cacheMax* limits. Duration like 1h, " +
"30m. Defaults to 1h. No effect unless a --cacheMax* limit is set."],
converter = [DurationConverter::class],
defaultValue = "1h")
var cachePruneInterval: Duration = Duration.ofHours(1)

@CommandLine.Option(
names = ["--warmupRevision"],
description =
Expand Down Expand Up @@ -220,6 +264,10 @@ class ServeCommand : Callable<Int> {
"size and memory. Defaults to false."])
var trackDeps = false

// The background cache sweeper, when a --cacheMax* limit is configured. Held so the shutdown path
// can stop it; null when pruning is disabled or the backend manages its own retention.
private var cachePruner: CachePruner? = null

override fun call(): Int {
org.koin.core.context.GlobalContext.stopKoin()
startKoin {
Expand Down Expand Up @@ -288,12 +336,41 @@ class ServeCommand : Callable<Int> {
ImpactedTargetsService(gitClient, hashService, depsTracked = trackDeps)

val ready = AtomicBoolean(false)
val server = BazelDiffServer(port, impactedTargetsService, requestTimeoutSeconds) { ready.get() }
val server =
BazelDiffServer(port, impactedTargetsService, requestTimeoutSeconds) { ready.get() }
server.start()
performInitialFetch(gitClient, hashService, ready, server)
// Start sweeping only after warmup, so the immediate first pass evaluates an already-warm
// cache.
cachePruner = buildCachePruner(storage)?.also { it.start() }
return server
}

/**
* The cache-pruning limits requested via the `--cacheMax*` flags (all-null = pruning disabled).
*/
fun cachePruneLimits(): CachePruneLimits =
CachePruneLimits(maxAge = cacheMaxAge, maxEntries = cacheMaxEntries, maxBytes = cacheMaxSize)

/**
* Builds the background [CachePruner] for [storage], or null when no `--cacheMax*` limit is set
* or the backend manages its own retention. Only a [PrunableHashCacheStorage] can be swept
* in-process -- a remote backend (e.g. S3) would instead rely on a bucket lifecycle policy, so a
* limit set against a non-prunable backend is reported and ignored rather than silently
* pretended-to.
*/
fun buildCachePruner(storage: HashCacheStorage): CachePruner? {
val limits = cachePruneLimits()
if (!limits.hasAny) return null
if (storage !is PrunableHashCacheStorage) {
System.err.println(
"[Warn] --cacheMax* is set but the cache backend does not support in-process pruning; " +
"ignoring (rely on the backend's own retention policy instead)")
return null
}
return CachePruner(storage, limits, cachePruneInterval)
}

/**
* Performs the startup git fetch (unless [noInitialFetch]) and flips [ready] to true once the
* clone is known good. On a fetch failure the server is left running but un-ready ("lame duck")
Expand Down Expand Up @@ -357,13 +434,15 @@ class ServeCommand : Callable<Int> {
Runtime.getRuntime()
.addShutdownHook(
Thread {
cachePruner?.stop()
server.stop(1)
latch.countDown()
})
try {
latch.await()
} catch (e: InterruptedException) {
// Treated as a shutdown signal: stop serving and restore the interrupt flag.
cachePruner?.stop()
server.stop(1)
Thread.currentThread().interrupt()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.bazel_diff.cli.converter

import picocli.CommandLine.ITypeConverter
import picocli.CommandLine.TypeConversionException

/**
* Parses a human-friendly byte size such as `10GB`, `500MB`, `2g`, `1kb`, or a bare `1048576`
* (bytes when no unit is given). Units are base 1024 and case-insensitive: `b`, `k`/`kb`, `m`/`mb`,
* `g`/`gb`, `t`/`tb`. Returns the size in bytes.
*/
class ByteSizeConverter : ITypeConverter<Long> {
override fun convert(value: String): Long {
val match =
PATTERN.matchEntire(value.trim().lowercase())
?: throw TypeConversionException(
"invalid size '$value' (expected e.g. 1048576, 500mb, 10gb)")
val amount = match.groupValues[1].toLong()
val multiplier =
when (match.groupValues[2]) {
"",
"b" -> 1L
"k",
"kb" -> KB
"m",
"mb" -> KB * KB
"g",
"gb" -> KB * KB * KB
"t",
"tb" -> KB * KB * KB * KB
else -> throw TypeConversionException("invalid size unit in '$value'")
}
return amount * multiplier
}

private companion object {
const val KB = 1024L
val PATTERN = Regex("(\\d+)\\s*([a-z]*)")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.bazel_diff.cli.converter

import java.time.Duration
import picocli.CommandLine.ITypeConverter
import picocli.CommandLine.TypeConversionException

/**
* Parses a human-friendly duration such as `7d`, `36h`, `90m`, `45s`, or a combination like
* `1d12h30m`. Recognised unit suffixes are `d` (days), `h` (hours), `m` (minutes) and `s`
* (seconds). The whole string must be made up of `<number><unit>` tokens; anything else is rejected
* so a typo fails fast rather than being silently truncated.
*/
class DurationConverter : ITypeConverter<Duration> {
override fun convert(value: String): Duration {
val normalized = value.trim().lowercase()
val tokens = TOKEN.findAll(normalized).toList()
val consumed = tokens.sumOf { it.value.length }
if (tokens.isEmpty() || consumed != normalized.length) {
throw TypeConversionException(
"invalid duration '$value' (expected e.g. 7d, 36h, 90m, 45s, or 1d12h)")
}
var total = Duration.ZERO
for (token in tokens) {
val amount = token.groupValues[1].toLong()
total +=
when (token.groupValues[2]) {
"d" -> Duration.ofDays(amount)
"h" -> Duration.ofHours(amount)
"m" -> Duration.ofMinutes(amount)
"s" -> Duration.ofSeconds(amount)
else -> throw TypeConversionException("invalid duration unit in '$value'")
}
}
return total
}

private companion object {
val TOKEN = Regex("(\\d+)([dhms])")
}
}
72 changes: 72 additions & 0 deletions cli/src/main/kotlin/com/bazel_diff/server/CachePruner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.bazel_diff.server

import com.bazel_diff.log.Logger
import java.time.Duration
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

/**
* Periodically evicts entries from a [PrunableHashCacheStorage] to keep a long-running server's
* cache within [limits], preventing the per-commit-SHA cache from growing without bound.
*
* Sweeping runs on a single daemon thread so a clean JVM shutdown is never blocked, and uses a
* fixed *delay* (not rate): the next sweep starts [interval] after the previous one finishes, so a
* slow sweep over a large cache directory can never let runs pile up. The first sweep runs
* immediately on [start] so a freshly (re)started server reclaims disk at once rather than after a
* full interval.
*
* A no-op when [limits] sets no bounds ([CachePruneLimits.hasAny] is false).
*/
class CachePruner(
private val storage: PrunableHashCacheStorage,
private val limits: CachePruneLimits,
private val interval: Duration,
) : KoinComponent {
private val logger: Logger by inject()

private val executor: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "bazel-diff-cache-pruner").apply { isDaemon = true }
}

/**
* Schedules the recurring sweep (and an immediate first pass). Idempotent limits-wise: a no-op
* when no bound is configured.
*/
fun start() {
if (!limits.hasAny) {
logger.i { "cache pruning disabled: no --cacheMax* limit set" }
return
}
val periodSeconds = interval.seconds.coerceAtLeast(1)
executor.scheduleWithFixedDelay(::runOnce, 0, periodSeconds, TimeUnit.SECONDS)
logger.i { "cache pruner active (limits=$limits, sweeping every ${periodSeconds}s)" }
}

/** Stops the background sweeper. Safe to call more than once. */
fun stop() {
executor.shutdownNow()
}

/**
* Runs one prune pass. Public so tests can drive a sweep deterministically. Never throws: a
* transient filesystem error is logged and the next scheduled sweep retries, so a single failure
* cannot kill the sweeper thread (which would silently disable pruning for the process lifetime).
*/
fun runOnce() {
try {
val result = storage.prune(limits)
if (result.evicted > 0) {
logger.i {
"cache prune evicted ${result.evicted} of ${result.scanned} entries " +
"(${result.bytesFreed} bytes freed)"
}
}
} catch (e: Exception) {
logger.e(e) { "cache prune pass failed; retrying next interval" }
}
}
}
Loading
Loading