Skip to content

Commit 2893203

Browse files
feat: add bazel-diff serve HTTP query service (#29) (#393)
* feat: add `bazel-diff serve` HTTP query service (#29) Implements the "bazel-diff as a service" model from the BazelCon talk that inspired this repo (issue #29), as a first self-contained increment: core service + per-SHA hash cache. New `com.bazel_diff.server` package + `serve` subcommand: - HTTP/JSON server on the JDK's com.sun.net.httpserver (no new dependency) with GET /health and GET /impacted_targets?from&to[&targetType]. - Per-SHA hash cache behind a byte-oriented HashCacheStorage interface (LocalDiskHashCacheStorage impl, atomic writes); cache key folds in a fingerprint of the query-affecting flags. The interface is ready for a remote (e.g. S3) backend. - GitClient/ProcessGitClient: subprocess git fetch/resolve/checkout. - HashService (per-SHA generate+cache, single workspace lock) and ImpactedTargetsService, which reuse CalculateImpactedTargetsInteractor so results match `get-impacted-targets`. - Initial `git fetch` gates readiness; a fetch failure "lame-ducks" the instance (health stays 503) rather than self-repairing. Query-affecting flags mirror `generate-hashes`, so hashes the service produces match a cold CLI run. Tests: unit tests per component plus a real end-to-end test (E2ETest#testServeEndToEnd) that drives the live server over HTTP against a two-commit git repo with real `bazel query`. New code is ~97% line-covered. Docs: README "Query Service (experimental)" section, with the `serve` help wired into the generated CLI reference. Deferred to follow-ups: remote (S3) cache backend, OCI image, k8s manifests, load balancing / multi-instance, dynamic scaling, git auth config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: add in-process JGit engine for `serve` (default) Adds a JGit-backed GitClient so the query service performs git fetch / resolve / checkout in-process, without forking a `git` subprocess or requiring a `git` binary on the PATH. - JGitClient implements the existing GitClient interface (drop-in). - `--gitEngine` selects the backend: `jgit` (default, in-process) or `subprocess` (shells out to --gitPath, exact C-git behavior, useful for workspaces relying on checkout filters/hooks JGit doesn't run). - Pins org.eclipse.jgit 5.13.x (the last Java-8-compatible line, matching bazel-diff's documented minimum) and repins maven_install.json. Note: "in process" removes the subprocess only; the working tree is still materialized on disk because `bazel query` reads real files. JGit's InMemoryRepository can't back a bazel workspace. Tests: JGitClientTest (resolve/checkout/fetch incl. remote loop and error paths against a real repo) + engine-selection tests; the serve E2E now exercises JGit by default. New code remains ~97% line-covered. Docs: README + generated serve help document --gitEngine and the on-disk working-tree caveat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 84cf488 commit 2893203

24 files changed

Lines changed: 2255 additions & 10 deletions

MODULE.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ maven.install(
5050
artifacts = [
5151
"com.google.code.gson:gson:2.9.0@jar",
5252
"com.google.guava:guava:31.1-jre",
53+
# JGit powers the `serve` command's in-process git engine. Pinned to the 5.13.x line, the
54+
# last release series that still targets Java 8 (matching bazel-diff's documented minimum).
55+
"org.eclipse.jgit:org.eclipse.jgit:5.13.3.202401111512-r",
5356
"com.willowtreeapps.assertk:assertk-jvm:0.25",
5457
"info.picocli:picocli:4.6.3@jar",
5558
"io.insert-koin:koin-core-jvm:3.1.6",

README.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,66 @@ This will produce an impacted targets json list with target label, target distan
107107
]
108108
```
109109

110+
## Query Service (experimental)
111+
112+
Instead of running `generate-hashes` from scratch on every CI invocation, you can run `bazel-diff` as
113+
a long-running HTTP service that answers affectedness queries between two git revisions and caches
114+
the generated hashes per commit SHA. This is the "bazel-diff as a service" model described in the
115+
[BazelCon talk](https://youtu.be/9Dk7mtIm7_A?t=1875) that inspired this repo (see
116+
[issue #29](https://github.com/Tinder/bazel-diff/issues/29)).
117+
118+
Start the service against a dedicated git clone of your workspace:
119+
120+
```bash
121+
bazel-diff serve \
122+
--workspacePath /path/to/workspace-clone \
123+
--bazelPath bazel \
124+
--cacheDir /var/cache/bazel-diff \
125+
--port 8080
126+
```
127+
128+
On startup the service performs an initial `git fetch` and only then reports healthy. For each
129+
request it resolves the `from`/`to` revisions, generates (and caches, keyed by commit SHA) the hashes
130+
for each, and reuses the exact same affectedness logic as `get-impacted-targets`.
131+
132+
Endpoints:
133+
134+
* `GET /health` — returns `200 OK` once the initial fetch has completed, `503` otherwise. A load
135+
balancer should use this to route only to ready instances. If a fatal git error occurs at startup
136+
the instance "lame-ducks" itself by continuing to report `503` so the load balancer removes it.
137+
* `GET /impacted_targets?from=<rev>&to=<rev>` — returns the impacted targets as JSON. The optional
138+
`targetType` parameter (e.g. `&targetType=Rule,SourceFile`) filters by target type.
139+
140+
```bash
141+
curl 'http://localhost:8080/impacted_targets?from=main&to=my-feature-branch'
142+
```
143+
144+
```json
145+
{
146+
"from": "9a1c0e2…",
147+
"to": "3f7b8d4…",
148+
"impactedTargets": ["//foo:bar", "//foo:baz"]
149+
}
150+
```
151+
152+
Notes and current limitations:
153+
154+
* The service checks out revisions inside `--workspacePath`, so point it at a dedicated clone, not a
155+
working tree you edit. All workspace-mutating work (git checkout + `bazel query`) is serialized,
156+
so a single instance answers one cold query at a time; the per-SHA cache absorbs the rest.
157+
* Git operations run in-process via JGit by default (no `git` binary required). Pass
158+
`--gitEngine=subprocess` to shell out to the `git` binary at `--gitPath` instead -- useful for
159+
workspaces that depend on checkout filters or hooks that JGit does not run. Note that JGit only
160+
moves the git plumbing in-process; the working tree is still materialized on disk for `bazel query`.
161+
* Hashes are cached on local disk via `--cacheDir` and survive restarts. The cache layer is
162+
pluggable behind a byte-oriented interface so a remote backend (e.g. S3) can be added without
163+
touching callers.
164+
* Query-affecting flags (`--useCquery`, `--fineGrainedHashExternalRepos`, etc.) mirror
165+
`generate-hashes`, and are folded into the cache key so a server started with different flags never
166+
serves another configuration's cached hashes.
167+
* Containerization, multi-instance deployment manifests, and remote cache backends are not yet
168+
included.
169+
110170
<!-- BEGIN_SECTION: cli-help -->
111171
## CLI Interface
112172

@@ -133,6 +193,9 @@ Commands:
133193
lock, .bazelrc, bazel-diff version, flag set) and
134194
writes it as JSON. Used to decide whether a
135195
Firecracker snapshot is safe to consume.
196+
serve Runs bazel-diff as a long-running HTTP query service
197+
that returns the impacted targets between two git
198+
revisions, caching generated hashes per commit SHA.
136199
```
137200

138201
### `generate-hashes` command
@@ -326,6 +389,81 @@ Command-line utility to analyze the state of the bazel build graph
326389
Path to Bazel workspace directory. Required for module
327390
change detection.
328391
```
392+
393+
### `serve` command
394+
395+
```terminal
396+
Usage: bazel-diff serve [-hkvV] [--[no-]excludeExternalTargets]
397+
[--no-initial-fetch] [--[no-]useCquery]
398+
[-b=<bazelPath>] --cacheDir=<cacheDir>
399+
[--cqueryExpression=<cqueryExpression>]
400+
[--fineGrainedHashExternalReposFile=<fineGrainedHashExte
401+
rnalReposFile>] [--gitEngine=<gitEngine>]
402+
[--gitPath=<gitPath>] [--port=<port>]
403+
[-s=<seedFilepaths>] -w=<workspacePath>
404+
[-co=<bazelCommandOptions>]...
405+
[--cqueryCommandOptions=<cqueryCommandOptions>]...
406+
[--fineGrainedHashExternalRepos=<fineGrainedHashExternal
407+
Repos>]...
408+
[--ignoredRuleHashingAttributes=<ignoredRuleHashingAttri
409+
butes>]... [-so=<bazelStartupOptions>]...
410+
Runs bazel-diff as a long-running HTTP query service that returns the impacted
411+
targets between two git revisions, caching generated hashes per commit SHA.
412+
-b, --bazelPath=<bazelPath>
413+
Path to Bazel binary. If not specified, the Bazel
414+
binary available in PATH will be used.
415+
--cacheDir=<cacheDir> Directory where generated hashes are cached per
416+
commit SHA. Persists across restarts.
417+
-co, --bazelCommandOptions=<bazelCommandOptions>
418+
Additional space separated Bazel command options
419+
used when invoking `bazel query`
420+
--cqueryCommandOptions=<cqueryCommandOptions>
421+
Additional space separated Bazel command options
422+
used when invoking `bazel cquery`. No effect
423+
unless --useCquery is set.
424+
--cqueryExpression=<cqueryExpression>
425+
Custom cquery expression to use instead of the
426+
default. No effect unless --useCquery.
427+
--[no-]excludeExternalTargets
428+
If true, exclude external targets (do not query
429+
//external:all-targets).
430+
--fineGrainedHashExternalRepos=<fineGrainedHashExternalRepos>
431+
Comma separated list of external repos for which
432+
fine-grained hashes are computed.
433+
--fineGrainedHashExternalReposFile=<fineGrainedHashExternalReposFile>
434+
A text file with a newline separated list of
435+
external repos. Mutually exclusive with
436+
--fineGrainedHashExternalRepos.
437+
--gitEngine=<gitEngine>
438+
Git backend: 'jgit' (in-process, no git binary
439+
required) or 'subprocess' (shells out to
440+
--gitPath). Defaults to 'jgit'.
441+
--gitPath=<gitPath> Path to the git binary, used only when
442+
--gitEngine=subprocess. Defaults to 'git' on the
443+
PATH.
444+
-h, --help Show this help message and exit.
445+
--ignoredRuleHashingAttributes=<ignoredRuleHashingAttributes>
446+
Attributes that should be ignored when hashing rule
447+
targets.
448+
-k, --[no-]keep_going Run `bazel query` with --keep_going. Defaults to
449+
true.
450+
--no-initial-fetch Skip the initial 'git fetch' before reporting
451+
healthy. Useful for local/offline testing.
452+
--port=<port> Port to listen on. Defaults to 8080.
453+
-s, --seed-filepaths=<seedFilepaths>
454+
A text file with a newline separated list of
455+
filepaths used as a SHA256 seed for all targets.
456+
-so, --bazelStartupOptions=<bazelStartupOptions>
457+
Additional space separated Bazel client startup
458+
options used when invoking Bazel
459+
--[no-]useCquery If true, use cquery instead of query when
460+
generating dependency graphs.
461+
-v, --verbose Display query string, missing files and elapsed time
462+
-V, --version Print version information and exit.
463+
-w, --workspacePath=<workspacePath>
464+
Path to the Bazel workspace git clone the service
465+
checks out and queries.
466+
```
329467
<!-- END_SECTION: cli-help -->
330468

331469
### What does the SHA256 value of `generate-hashes` represent?

cli/BUILD

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ kt_jvm_library(
3838
"@bazel_diff_maven//:info_picocli_picocli",
3939
"@bazel_diff_maven//:io_insert_koin_koin_core_jvm",
4040
"@bazel_diff_maven//:org_apache_commons_commons_pool2",
41+
"@bazel_diff_maven//:org_eclipse_jgit_org_eclipse_jgit",
4142
"@bazel_diff_maven//:org_jetbrains_kotlinx_kotlinx_coroutines_core_jvm",
4243
],
4344
)
@@ -212,6 +213,54 @@ kt_jvm_test(
212213
],
213214
)
214215

216+
kt_jvm_test(
217+
name = "ServeCommandTest",
218+
test_class = "com.bazel_diff.cli.ServeCommandTest",
219+
runtime_deps = [":cli-test-lib"],
220+
)
221+
222+
kt_jvm_test(
223+
name = "LocalDiskHashCacheStorageTest",
224+
test_class = "com.bazel_diff.server.LocalDiskHashCacheStorageTest",
225+
runtime_deps = [":cli-test-lib"],
226+
)
227+
228+
kt_jvm_test(
229+
name = "GitClientTest",
230+
test_class = "com.bazel_diff.server.GitClientTest",
231+
runtime_deps = [":cli-test-lib"],
232+
)
233+
234+
kt_jvm_test(
235+
name = "JGitClientTest",
236+
test_class = "com.bazel_diff.server.JGitClientTest",
237+
runtime_deps = [":cli-test-lib"],
238+
)
239+
240+
kt_jvm_test(
241+
name = "HashServiceTest",
242+
jvm_flags = [
243+
"-Dnet.bytebuddy.experimental=true",
244+
],
245+
test_class = "com.bazel_diff.server.HashServiceTest",
246+
runtime_deps = [":cli-test-lib"],
247+
)
248+
249+
kt_jvm_test(
250+
name = "ImpactedTargetsServiceTest",
251+
jvm_flags = [
252+
"-Dnet.bytebuddy.experimental=true",
253+
],
254+
test_class = "com.bazel_diff.server.ImpactedTargetsServiceTest",
255+
runtime_deps = [":cli-test-lib"],
256+
)
257+
258+
kt_jvm_test(
259+
name = "BazelDiffServerTest",
260+
test_class = "com.bazel_diff.server.BazelDiffServerTest",
261+
runtime_deps = [":cli-test-lib"],
262+
)
263+
215264
kt_jvm_library(
216265
name = "cli-test-lib",
217266
testonly = True,

cli/src/main/kotlin/com/bazel_diff/cli/BazelDiff.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import picocli.CommandLine.Spec
1212
GenerateHashesCommand::class,
1313
GetImpactedTargetsCommand::class,
1414
WarmupCommand::class,
15-
FingerprintCommand::class],
15+
FingerprintCommand::class,
16+
ServeCommand::class],
1617
mixinStandardHelpOptions = true,
1718
versionProvider = VersionProvider::class,
1819
)

0 commit comments

Comments
 (0)