Skip to content

Add optional artefact index reader for startup scanning - #16000

Open
jamesfredley wants to merge 3 commits into
8.0.xfrom
feat/artefact-index-seed
Open

Add optional artefact index reader for startup scanning#16000
jamesfredley wants to merge 3 commits into
8.0.xfrom
feat/artefact-index-seed

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add an optional internal artefact-index reader for META-INF/grails/artefacts.idx
  • ApplicationArtefactScanner uses a valid application-root index (honoring packageNames) and safely falls back to classpath scanning when the index is absent, malformed, unresolvable, or linkage-invalid
  • Deterministic ordering, deduplication, and transformed-class inclusion preserved

Why

Today, artefact discovery (ApplicationArtefactScannerClassPathScanner) runs at every application boot: it resource-pattern-matches the classpath per package, reads each candidate .class file's metadata via ASM, and checks it for a grails.*-prefixed annotation. That's proportional to the number of classes on the classpath, not the number of actual artefacts, and it's pure runtime I/O + reflection-adjacent work that produces the same result on every single boot for a given build.

An artefact index moves that discovery from "boot time, every time" to "build time, once": a producer (not part of this PR — see Scope note) would emit the flat list of artefact class names once, at build time, and boot then becomes a plain file read plus loading exactly those classes — no classpath scanning, no per-class metadata reads. That's the startup-speed win, and it scales with the number of artefacts instead of the size of the classpath.

This is consistent with a direction the team is already moving in elsewhere: shifting checks/work that don't need to happen at runtime to compile/build time (e.g. the compile-time SQL injection prevention work). This PR proposes applying that same idea to artefact discovery.

Scope note

This is a starter/seed: it adds only the reader and its safe fallback. The index producer is not included, so the optimization activates only once an artefacts.idx is emitted into the application code-source root; until then behavior is unchanged (fallback scanning).

Verification

  • :grails-core:test --tests ApplicationArtefactScannerSpec
  • :grails-core:test
  • git diff --check

This is an AI-generated starting point for build-time artefact indexing.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces an internal, optional artefact index reader (META-INF/grails/artefacts.idx) to speed up application startup artefact discovery, while preserving the existing classpath-scan behavior via safe fallback when the index can’t be used.

Changes:

  • Add ArtefactIndexReader to load application-root artefacts.idx (ordered + deduped) and return null to trigger fallback scanning on failures.
  • Update ApplicationArtefactScanner to prefer indexed classes when available and otherwise use the existing ClassPathScanner, then append transformed classes.
  • Add a comprehensive ApplicationArtefactScannerSpec plus small fixture artefacts to validate ordering, deduplication, package filtering, and fallback scenarios.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java New internal reader for the optional application artefact index with “all-or-nothing” validation behavior.
grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy Prefer index-based loading (when valid) and use LinkedHashSet to preserve deterministic ordering/deduplication semantics.
grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy Adds coverage for index ordering, deduplication, fallback behavior, dependency index isolation, linkage failures, and transformed-class inclusion.
grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy Test fixture used to validate packageName filtering for indexed entries.
grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy Test fixture used to validate packageName filtering exclusion for indexed entries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +36 to +39
* <p>The index is UTF-8 text at {@value #RESOURCE_NAME}, with one fully qualified
* class name per nonempty line. Any unreadable or unresolvable entry rejects the
* complete index so callers can use their normal classpath scan.</p>
*/
@bito-code-review

Copy link
Copy Markdown

The Javadoc in ArtefactIndexReader.java has been updated to accurately reflect the current implementation. It now states that the index contains one fully qualified class name per nonempty line and clarifies that any unreadable or unresolvable entry (including empty lines, as enforced by the className.isEmpty() check) causes the entire index to be rejected, falling back to a standard classpath scan.

grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java

* <p>The index is UTF-8 text at {@value #RESOURCE_NAME}, with one fully qualified
 * class name per nonempty line. Any unreadable or unresolvable entry rejects the
 * complete index so callers can use their normal classpath scan.</p>

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 51.5133%. Comparing base (b980413) to head (7662a26).
⚠️ Report is 253 commits behind head on 8.0.x.

Additional details and impacted files

Impacted file tree graph

@@               Coverage Diff                @@
##             8.0.x     #16000         +/-   ##
================================================
+ Coverage         0   51.5133%   +51.5133%     
- Complexity       0      17800      +17800     
================================================
  Files            0       2040       +2040     
  Lines            0      95618      +95618     
  Branches         0      16597      +16597     
================================================
+ Hits             0      49256      +49256     
- Misses           0      39052      +39052     
- Partials         0       7310       +7310     
Files with missing lines Coverage Δ
...ails/boot/config/ApplicationArtefactScanner.groovy 75.0000% <100.0000%> (ø)
...groovy/grails/boot/config/ArtefactIndexReader.java 100.0000% <100.0000%> (ø)

... and 2038 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@davydotcom

Copy link
Copy Markdown
Contributor

Trying to understand the intent behind this one. Is it for startup speed and if so, how does it help there. PR description just needs a little more to help me understand intent.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial review is below, but I think this needs discussed more in the weekly since it's an architectural shift. In general, I'm ok moving to more compile time based fixes for the performance benefit, but we need to agree as a team. Putting a PR out here prior to a discussion is concerning, since it could be merged without a wider architectural discussion.

URL resource = new URL(IOUtils.findRootResource(applicationClass), RESOURCE_NAME);
Set<Class> classes = new LinkedHashSet<>();
return readResource(resource, applicationClass.getClassLoader(), packageNames, classes) ? classes : null;
} catch (IOException ignored) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IOUtils.findRootResource throws IllegalStateException, not IOException, when the class resource can't be resolved (targetClass.getResource(...) returning null — e.g. an application class from a classloader that doesn't expose .class resources). That escapes this catch and would fail startup, where today the same situation just scans. Since the whole contract of this reader is "never make things worse than the fallback," this should catch that too (e.g. catch (IOException | RuntimeException)).

private ArtefactIndexReader() {
}

static Collection<Class> read(Class<?> applicationClass, Collection<String> packageNames) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two operational concerns for production startup code:

  1. Silence. Both outcomes are invisible — no log when the index is used, none when it's rejected. A corrupted index silently degrades to slow scanning and nobody finds out; a working index can't be confirmed either. Suggest debug-level logs for "index used (N entries)" and "index rejected, falling back".
  2. Stale-index hazard. A valid but incomplete index is the dangerous case: developer adds an artefact, index isn't regenerated, and the new class is silently absent from the application — no error, no fallback. The producer side will need a freshness guarantee (or the index should carry a hash/marker the reader can validate), and a kill-switch system property to force scanning would be a cheap escape hatch worth adding in the seed.

}
}

private static boolean isInPackage(String className, Collection<String> packageNames) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth documenting the semantic differences from the ClassPathScanner path this replaces, since the future index producer has to compensate for them:

  • The scanner only returns classes carrying an annotation whose name starts with grails. (default annotationFilter); the reader trusts every listed entry with no annotation check, so a hand-edited or buggy index can inject arbitrary classes into the artefact set.
  • The scanner skips DEFAULT_IGNORED_ROOT_PACKAGES (com, org, net, …) even when explicitly passed as packageNames; the reader honors them.

Both are fine if the producer mirrors scan semantics exactly, but that contract currently lives nowhere — a sentence in the class javadoc would pin it.

BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String className;
while ((className = reader.readLine()) != null) {
if (className.isEmpty()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rejecting the entire index on an empty line is stricter than it needs to be — a trailing blank line is the most common artifact of text-file generation and concatenation (the spec's own writeIndex has to .trim() to avoid it). Skipping blank lines (continue) keeps the strict-reject behavior for genuinely malformed content while tolerating the boring case. If strictness is intentional as a whole-file integrity signal, the javadoc should say the producer must not emit blank lines, including trailing ones.

@jamesfredley jamesfredley moved this to Todo in Apache Grails Jul 24, 2026
borinquenkid and others added 2 commits July 26, 2026 10:58
…Reader

isInPackage() had untested branches for the default-package match
(packageName.isEmpty()) and for defensively skipping a null entry in
packageNames, leaving ArtefactIndexReader.java's patch coverage at 80%.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Five review comments (Copilot, jdaugherty) on the artefact-index reader
were left unaddressed by the prior commit, which only closed a coverage
gap:

- Widen read()'s catch to IOException | RuntimeException so a failure
  in IOUtils.findRootResource (e.g. IllegalStateException) or class
  loading (e.g. SecurityException) falls back to scanning instead of
  failing startup.
- Add debug logging for index-used / index-rejected / index-disabled
  outcomes, since both paths were previously silent.
- Add a grails.artefactIndex.disabled system property kill-switch to
  force classpath scanning.
- Skip blank lines instead of rejecting the whole index on one; this
  also aligns the code with the class javadoc, which already documented
  blank lines as insignificant.
- Document the semantic differences from ClassPathScanner (no
  annotation check, ignored-root-packages not applied) that an index
  producer must account for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@borinquenkid borinquenkid added this to the grails:8.0.0-RC1 milestone Jul 27, 2026
@testlens-app

testlens-app Bot commented Jul 27, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 7662a26
▶️ Tests: 41891 executed
⚪️ Checks: 59/59 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

5 participants