GroovyABI annotation to mark backwards compatibility - #2770
Conversation
…ler and have special requirements for backwards compatibility
|
Looks like I accidentally added the annotation to some groovy.* classes. Please ignore those I am going to remove their annotation before merge (if we merge) |
✅ All tests passed ✅🏷️ Commit: ce12a74 Learn more about TestLens at testlens.app. |
|
I haven't done a proper review yet but here is an early AI read:
|
|
Just a few thoughts after reading through the change. The idea makes a lot of sense to me — I only have some questions about how we keep the marker consistent and easy to maintain. What works wellThe problem is real: things the compiler (or transforms) call directly in the runtime are a compatibility surface that’s easy to miss. Classic call sites, A few things in this commit already feel right:
Annotation shape and docsRetention@Retention(SOURCE)
public @interface GroovyABI {
String since() default "";
}With If we later want a small check like “don’t drop a compiler-referenced member by accident,” It might also be worth a short note in
|
| Area | What happens |
|---|---|
Maps.of(...) |
~100 identical annotations on a huge file |
CallSite |
every call* / callSafe* / … overload |
HashCodeHelper.updateHash |
every overload |
| DTT box/unbox | every method; the class itself isn’t marked |
Would it make sense to default to the type, and only mark members when since differs or when only part of the type is ABI? That would cut a lot of noise without losing the refinement pattern you already use on ScriptBytecodeAdapter. Maps is the clearest case — the per-overload marks don’t really say more than one class-level annotation would.
How complete does the inventory need to be?
A partial list is still useful as a start. The risk is that people read “no annotation” as “safe to change.” A few busy places show the gap:
| Surface | Role | Currently |
|---|---|---|
ScriptBytecodeAdapter |
main classic bridge | class-level — good |
DefaultTypeTransformation |
box/unbox and casts (castToType, etc.) |
box/unbox only |
NumberMath |
arithmetic / compare as well as bitwise | mostly bitwise / shift / mod |
InvokerHelper |
property / invoke helpers | a subset; several with ? |
CallSiteArray |
classic linkage (ctor + array / owner) |
constructor only |
Also, two annotations sit on private methods, which outside bytecode can’t call:
AssertionRenderer.render()(private) — the public staticrender(String, ValueRecorder)is probably what we meanFieldValues.findField(...)(private helper)
Those look accidental; moving them to the public entry points (or dropping them) would keep the meaning clear.
Looking a bit further ahead
Keeping hundreds of hand-placed marks up to date will be hard. A lot of this surface is already “registered” in the compiler via MethodCaller / multi-adapters, indy bootstraps, and transform-generated calls. Even a simple test that checks known registration points against @GroovyABI would help later. No need to build that now — just something to keep in mind so the first cut doesn’t fight that idea.
For v1, two shapes both seem reasonable:
- Narrow but complete — e.g. call sites +
ScriptBytecodeAdapter+ indy bootstraps, fully covered; or - Wide pass like now, but with type-level defaults, no
?, no private members, and a clear rule for what belongs (always-emitted bridges vs occasional DGM / static-compile paths).
Right now a few DGM / StringGroovyMethods / NumberMath bits sit next to the core bridges without saying why those and not others. One sentence of guidance would go a long way so we don’t slowly mark half the GDK.
A few edits that aren’t about @GroovyABI
These are easy to miss in the same commit:
| File | Change |
|---|---|
BinaryExpressionHelper |
drops an unused assignment after boxing |
ClosureWriter |
unused callX import |
StaticTypesCallSiteWriter |
whitespace on "or", and drops case "implies": |
The first two are harmless. The implies case changes STC dispatch for number×number implies (it no longer takes the optimized path). Even if that path is rare, it might be clearer in its own change — or with a short note and a test if we keep it here. Splitting pure inventory from compiler behaviour also makes history and reverts easier.
Optional next steps
Nothing urgent — more a suggested order if we iterate:
- Spell out the contract briefly in
COMPATIBILITY.md(what it means, class vs member, version format, how it relates to public API). - Tighten the annotation (retention,
sincerules, Javadoc,groovy.*wording). - Prefer type-level marks when everything shares the same
since. - Clean the inventory: resolve/drop
?, fix private members, either finish the core bridges or document an explicit v1 subset. - Split (or call out) the unrelated compiler cleanups and the
implieschange. - Later: a lightweight check against known compiler registration points.
Closing
I like the direction. Making the compiler↔runtime surface visible will help future work. The main things I’d still like to settle are: who the annotation is for (humans only vs tooling), leaning on type-level marks so the inventory stays readable, and making the marked set either complete for a stated scope or clearly “work in progress,” so it stays a trustworthy signal.
|
I agree with most what you both said, but since this started to be a tiring piece of work (5h of finding methods initial commits, compare with tags and so on) I wanted first to get some feedback before proceeding.
[...}
agreed will do. The primary purpose was information for the developer, not for a tool, that is why I did not think of that.
yeah, I have to investigate further on those.
the longer version should be always used, will change that.
ah well... ok
I had to first to work a bit with it and then let it settle. I think now since should be mandatory. [...]
yeah, those have to go. [...]
if it is every public method in that file, then we can mark it on type level using the earliest version and if there are later introduction we can use the later version directly on those. But what if we have mixed in public methods that are not used from bytecode? An internal marked class with bytecode ABI markers on some of the public methods is still allowed to change those public methods.
I think castToType for example shows the problem. I did not mark it, because I did not verify its usage. I did not work through InvocationWriter for example. And while that uses castToType, it is from SBC. I did another search and could not find a usage by the compiler. So maybe it was in the past? Tranditionally there is SBC.castToType that uses that now. So most likely the method was never used directly by compiler produced bytecode. But can I be sure? No. It means digging deep into the commit history and trying to find the usage on an checkout of an old version most likely. The probability is high though it was never used from the compiler directly. Thus the annotations go on the box/unbox methods only, none on the class level. [...]
A once placed marker does not need an update. The MethodCaller/multi-adapters help, but what if we decide to use a different method in the future? The old entry point is potentially lost and forgotten, then maybe removed and we broke older programs. Extracting StringDGM for example broke older programs because some of the string methods in there where used directly. That did happen in 3.0.0, and it took till 3.0.24 I think before we fixed it. transform generated did proof to be more difficult actually. Only following the callX usages did help, but sometimes the call is abstracted through several levels and it gets difficult fast. indy bootstraps are another good example. We broke that before. But it is only the bootstrap method we have to consider here, not methods we produce method handles for. Unless the handle is stored in the class bytecode, then we have to deal with handles as well. So far the bootstrap method works like an isolation layer here. Adding new bootstrap methods like some of the Groovy 6 methods are a different story. There are also for example for methods used by runtime generated classes. These are also not to be considered. [...]
I tried to give those in the javadoc of the annotation, maybe needs to be improved.
The implies drop should maybe be extracted. But fact is we do not compile 1==>1 to 1.implies(1). We compile that by converting the numbers to boolean (in a quite inefficient way btw.) and then handle it as boolean intrinsic. The path I removed is never visited. In other words the optimization was never in BinaryExpressionHelper. If I where wrong, there should have been failing tests. Though I have the feeling we lack tests with implies and static compilation and with anything but booleans as well.
agreed I guess. Since the idea was well received so far I also have to add a JIRA issue. |
mainly methods as used through the compiler and have special requirements for backwards compatibility as they are used by the compiler
I put this to discussion if we want to have this annotation. I think it would be useful, especially to see the actual "surface" we exhibit through compilation in methods, that are not in groovy.*
This is btw probably not complete. It was a lot of work to find all those cases and their respective version.
@paulk-asert and @daniellansun and @eric-milles would be nice to hear your opinion