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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,24 @@ curl 'http://localhost:8080/impacted_targets?from=main&to=my-feature-branch'
}
```

* `POST /impacted_targets` — the same query as a JSON body, which additionally accepts a
`modifiedFilepaths` list to speed up cold hashing on large repositories. Body fields: `from` and
`to` (required), `targetType` (optional array), and `modifiedFilepaths` (optional array of
workspace-relative paths that changed between the two revisions, e.g. from
`git diff --name-only <from> <to>`). When `modifiedFilepaths` is present the server reads and
hashes the *content* of only those files on **both** revisions and treats every other source file
as unchanged, turning an O(all source files) content read into O(changed files) — the same
optimization as `generate-hashes --modified-filepaths`. The list must be a **superset** of what
actually changed: a truly-changed file left off it is content-skipped on both sides and its
impacted targets are missed (hence experimental). Omit it (or send `[]`) for the full-content hash,
identical to the GET form. `POST /impacted_targets_with_distances` accepts the same body.

```bash
curl -X POST http://localhost:8080/impacted_targets \
-H 'Content-Type: application/json' \
-d '{"from":"main","to":"my-feature-branch","modifiedFilepaths":["foo/BUILD.bazel","foo/bar.py"]}'
```

* `GET /impacted_targets_with_distances?from=<rev>&to=<rev>` — like `/impacted_targets`, but each
impacted target is annotated with its build-graph distance metrics: `targetDistance` (the number of
dependency hops to the nearest directly-changed target) and `packageDistance` (how many of those
Expand Down Expand Up @@ -223,6 +241,13 @@ Notes and current limitations:
* 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.
* `modifiedFilepaths` (POST only) is scoped per request, not a server flag. A scoped hash of a
revision is only comparable to another revision hashed with the *same* set, so cached scoped
entries are keyed by `<sha>.<fingerprint>.<digest-of-the-set>` — never mixed with, or served in
place of, the full-content `<sha>.<fingerprint>` entry. The trade-off: on the scoped path the
shared-base full-hash cache is not reused (each distinct changed-set re-hashes the base), but each
such hash is cheaper because it skips reading unchanged files. The extra entries are bounded by the
same LRU `--cacheMax*` pruning as everything else.
* Containerization, multi-instance deployment manifests, and remote cache backends are not yet
included.

Expand Down
137 changes: 113 additions & 24 deletions cli/src/main/kotlin/com/bazel_diff/server/BazelDiffServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package com.bazel_diff.server

import com.bazel_diff.log.Logger
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import java.io.File
import java.io.IOException
import java.net.InetSocketAddress
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import java.util.concurrent.Callable
import java.util.concurrent.ExecutionException
import java.util.concurrent.ExecutorService
Expand All @@ -26,9 +29,15 @@ import org.koin.core.component.inject
* lame-ducked by a fatal git error.
* - `GET /impacted_targets?from=<rev>&to=<rev>[&targetType=Rule,SourceFile]` -- returns `{"from":
* <sha>, "to": <sha>, "impactedTargets": [...]}`.
* - `GET /impacted_targets_with_distances?from=<rev>&to=<rev>[&targetType=...]` -- like above but
* each impacted target is `{"label", "targetDistance", "packageDistance"}`. Requires the server
* to have been started with `--trackDeps`; returns `400` otherwise.
* - `POST /impacted_targets` with a JSON body `{"from", "to", "targetType"?: [...],
* "modifiedFilepaths"?: [...]}` -- same result as the GET form, but additionally accepts
* `modifiedFilepaths` (workspace-relative paths changed between the revisions) to scope content
* hashing (see [ImpactedTargetsService]/[HashService]). A changed-file list is too large for a
* URL, so it rides in the body; a GET is always the full-content hash.
* - `GET /impacted_targets_with_distances?from=<rev>&to=<rev>[&targetType=...]` (and its `POST`
* form) -- like above but each impacted target is `{"label", "targetDistance",
* "packageDistance"}`. Requires the server to have been started with `--trackDeps`; returns `400`
* otherwise.
* - `GET /metrics` -- a JSON snapshot of the instance (version, uptime, readiness, git engine,
* cache size usage, JVM heap) when a [metricsProvider] is wired. Intentionally not gated on
* readiness, so a scrape of an un-ready or lame-ducked instance still returns data.
Expand Down Expand Up @@ -120,47 +129,75 @@ class BazelDiffServer(
}

private fun handleImpactedTargets(exchange: HttpExchange) =
handleQuery(exchange) { from, to, targetTypes ->
impactedTargetsProvider.getImpactedTargets(from, to, targetTypes)
handleQuery(exchange) { from, to, targetTypes, modifiedFilepaths ->
impactedTargetsProvider.getImpactedTargets(from, to, targetTypes, modifiedFilepaths)
}

private fun handleImpactedTargetsWithDistances(exchange: HttpExchange) =
handleQuery(exchange) { from, to, targetTypes ->
impactedTargetsProvider.getImpactedTargetsWithDistances(from, to, targetTypes)
handleQuery(exchange) { from, to, targetTypes, modifiedFilepaths ->
impactedTargetsProvider.getImpactedTargetsWithDistances(
from, to, targetTypes, modifiedFilepaths)
}

/** Normalized inputs for a query, parsed from either a GET query string or a POST JSON body. */
private data class QueryInputs(
val from: String,
val to: String,
val targetTypes: Set<String>?,
val modifiedFilepaths: Set<Path>,
)

/**
* JSON body accepted on `POST /impacted_targets(_with_distances)`. All fields optional here so a
* missing one becomes a clear `400` rather than a Gson failure.
*/
private data class ImpactedTargetsRequestBody(
val from: String? = null,
val to: String? = null,
val targetType: List<String>? = null,
val modifiedFilepaths: List<String>? = null,
)

/** Local signal that request parsing failed; [handleQuery] maps it to a `400`. */
private class BadRequestException(message: String) : Exception(message)

/**
* Shared handling for the impacted-targets endpoints: enforces GET + readiness, parses and
* validates `from`/`to`/`targetType`, then serializes the result of [compute] as JSON, mapping
* the known failure modes to the appropriate status codes.
* Shared handling for the impacted-targets endpoints: enforces GET/POST + readiness, parses and
* validates the request, then serializes the result of [compute] as JSON, mapping the known
* failure modes to the appropriate status codes.
*/
private fun handleQuery(
exchange: HttpExchange,
compute: (from: String, to: String, targetTypes: Set<String>?) -> Any
compute:
(from: String, to: String, targetTypes: Set<String>?, modifiedFilepaths: Set<Path>) -> Any
) =
withExchange(exchange) {
if (!exchange.requestMethod.equals("GET", ignoreCase = true)) {
respondJson(exchange, 405, mapOf("error" to "method not allowed, use GET"))
val isGet = exchange.requestMethod.equals("GET", ignoreCase = true)
val isPost = exchange.requestMethod.equals("POST", ignoreCase = true)
if (!isGet && !isPost) {
respondJson(exchange, 405, mapOf("error" to "method not allowed, use GET or POST"))
return@withExchange
}
if (!readiness()) {
respondJson(exchange, 503, mapOf("error" to "service not ready"))
return@withExchange
}

val params = parseQuery(exchange.requestURI.rawQuery)
val from = params["from"]
val to = params["to"]
if (from.isNullOrEmpty() || to.isNullOrEmpty()) {
respondJson(
exchange, 400, mapOf("error" to "missing required query parameters 'from' and 'to'"))
return@withExchange
}
val targetTypes =
params["targetType"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet()
val inputs =
try {
if (isPost) parsePostBody(exchange) else parseGetQuery(exchange)
} catch (e: BadRequestException) {
respondJson(exchange, 400, mapOf("error" to (e.message ?: "bad request")))
return@withExchange
}

try {
respondJson(exchange, 200, computeWithTimeout { compute(from, to, targetTypes) })
respondJson(
exchange,
200,
computeWithTimeout {
compute(inputs.from, inputs.to, inputs.targetTypes, inputs.modifiedFilepaths)
})
} catch (e: TimeoutException) {
logger.w { "request exceeded ${requestTimeoutSeconds}s timeout, abandoning" }
respondJson(
Expand Down Expand Up @@ -214,6 +251,58 @@ class BazelDiffServer(
}
}

/**
* Parses a GET request's `from`/`to`/`targetType` from the query string. A GET never carries
* modified filepaths (a changed-file list is too large for a URL), so it is always the
* full-content hash. Throws [BadRequestException] when `from`/`to` are missing.
*/
private fun parseGetQuery(exchange: HttpExchange): QueryInputs {
val params = parseQuery(exchange.requestURI.rawQuery)
val from = params["from"]
val to = params["to"]
if (from.isNullOrEmpty() || to.isNullOrEmpty()) {
throw BadRequestException("missing required query parameters 'from' and 'to'")
}
val targetTypes =
params["targetType"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet()
return QueryInputs(from, to, targetTypes, emptySet())
}

/**
* Parses a POST request's JSON body into [QueryInputs], including the optional
* `modifiedFilepaths` scope (converted to workspace-relative [Path]s the same way
* `--seed-filepaths` reads them). Throws [BadRequestException] for a malformed/empty body or a
* missing `from`/`to`. An empty or all-blank `targetType` collapses to null (no filter, i.e. all
* types).
*/
private fun parsePostBody(exchange: HttpExchange): QueryInputs {
val raw = exchange.requestBody.readBytes().toString(StandardCharsets.UTF_8)
val body =
try {
gson.fromJson(raw, ImpactedTargetsRequestBody::class.java)
} catch (e: JsonSyntaxException) {
throw BadRequestException("invalid JSON body: ${e.message}")
} ?: throw BadRequestException("missing JSON body with 'from' and 'to'")
val from = body.from
val to = body.to
if (from.isNullOrEmpty() || to.isNullOrEmpty()) {
throw BadRequestException("missing required fields 'from' and 'to'")
}
val targetTypes =
body.targetType
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.toSet()
?.takeIf { it.isNotEmpty() }
val modifiedFilepaths =
body.modifiedFilepaths
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.map { File(it).toPath() }
?.toSet() ?: emptySet()
return QueryInputs(from, to, targetTypes, modifiedFilepaths)
}

private fun parseQuery(rawQuery: String?): Map<String, String> {
if (rawQuery.isNullOrEmpty()) return emptyMap()
return rawQuery
Expand Down
55 changes: 43 additions & 12 deletions cli/src/main/kotlin/com/bazel_diff/server/HashService.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.bazel_diff.server

import com.bazel_diff.bazel.BazelModService
import com.bazel_diff.extensions.toHexString
import com.bazel_diff.hash.BuildGraphHasher
import com.bazel_diff.hash.TargetHash
import com.bazel_diff.hash.sha256
import com.bazel_diff.interactor.DeserialiseHashesInteractor
import com.bazel_diff.interactor.HashFileData
import com.bazel_diff.log.Logger
Expand All @@ -20,8 +22,17 @@ import org.koin.core.component.inject
* Split behind an interface so [ImpactedTargetsService] can be unit-tested with a fake.
*/
interface HashProvider {
/** Returns hash data for [sha] (a fully-resolved commit SHA), generating + caching on miss. */
fun getHashes(sha: String): HashFileData
/**
* Returns hash data for [sha] (a fully-resolved commit SHA), generating + caching on miss.
*
* [modifiedFilepaths], when non-empty, scopes source-file *content* hashing to just those
* workspace-relative paths (see [BuildGraphHasher.hashAllBazelTargetsAndSourcefiles]) and is
* folded into the cache key, so a content-scoped entry is never served to -- or mixed with -- a
* request using a different set. Empty (the default) is the full-content hash and today's per-SHA
* cache key. Correctness requires the caller to apply the *same* set to both revisions of a
* comparison and for it to be a superset of what actually changed (see [ImpactedTargetsService]).
*/
fun getHashes(sha: String, modifiedFilepaths: Set<Path> = emptySet()): HashFileData

/**
* Runs [block] with the workspace checked out at [sha], holding the workspace lock for the
Expand Down Expand Up @@ -61,16 +72,37 @@ class HashService(
// Guards every workspace-mutating operation: only one checkout + query may run at a time.
private val generationLock = Any()

/** Cache key for [sha]: the SHA plus the config fingerprint, both filename-safe. */
fun cacheKey(sha: String): String = "$sha.$configFingerprint"
/**
* Cache key for [sha]: the SHA plus the config fingerprint, both filename-safe. A non-empty
* [modifiedFilepaths] appends a short digest of the (sorted) set so a content-scoped hash is only
* ever compared against another hash scoped by the same set -- never the full-content entry, nor
* a differently-scoped one. Empty (the common path) keeps the original `<sha>.<fingerprint>` key
* so GET requests and warmup keep sharing entries. The digest is order-independent (paths are
* sorted) and hex, so the on-disk `<key>.json` mapping stays filename-safe.
*/
fun cacheKey(sha: String, modifiedFilepaths: Set<Path> = emptySet()): String {
val base = "$sha.$configFingerprint"
if (modifiedFilepaths.isEmpty()) return base
val digest =
sha256 {
for (path in modifiedFilepaths.map { it.toString() }.sorted()) {
putBytes(path.toByteArray())
putBytes(byteArrayOf(0x0a))
}
}
.toHexString()
.take(12)
return "$base.$digest"
}

override fun getHashes(sha: String): HashFileData {
storage.get(cacheKey(sha))?.let { bytes ->
override fun getHashes(sha: String, modifiedFilepaths: Set<Path>): HashFileData {
val key = cacheKey(sha, modifiedFilepaths)
storage.get(key)?.let { bytes ->
logger.i { "Hash cache hit for $sha" }
return deserialiser.executeTargetHashWithMetadataFromString(
String(bytes, StandardCharsets.UTF_8))
}
return generate(sha)
return generate(sha, modifiedFilepaths, key)
}

override fun <T> withWorkspaceAt(sha: String, block: () -> T): T =
Expand All @@ -79,10 +111,10 @@ class HashService(
block()
}

private fun generate(sha: String): HashFileData =
private fun generate(sha: String, modifiedFilepaths: Set<Path>, key: String): HashFileData =
synchronized(generationLock) {
// Re-check under the lock: another thread may have generated this revision while we waited.
storage.get(cacheKey(sha))?.let {
storage.get(key)?.let {
logger.i { "Hash cache hit for $sha (after lock)" }
return deserialiser.executeTargetHashWithMetadataFromString(
String(it, StandardCharsets.UTF_8))
Expand All @@ -91,12 +123,11 @@ class HashService(
gitClient.checkout(sha)
val hashes =
buildGraphHasher.hashAllBazelTargetsAndSourcefiles(
seedFilepaths, ignoredRuleHashingAttributes)
seedFilepaths, ignoredRuleHashingAttributes, modifiedFilepaths)
val moduleGraphJson = runBlocking { bazelModService.getModuleGraphJson() }
val depEdges = depEdgesOf(hashes)
storage.put(
cacheKey(sha),
serialize(hashes, moduleGraphJson, depEdges).toByteArray(StandardCharsets.UTF_8))
key, serialize(hashes, moduleGraphJson, depEdges).toByteArray(StandardCharsets.UTF_8))
HashFileData(hashes, moduleGraphJson, depEdges)
}

Expand Down
Loading
Loading