A Guide on Binary Compatibility - need your input!

I’ve started working on a binary compatibility guide (targeted at library authors but will be equally useful for library users to spot potential binary incompatibilities).

Currently the plan is to cover topics like:

  1. What are evictions, binary and source compatibility
  2. Using Migration Manager for Scala
  3. Versioning scheme (i.e. same major version maintains binary compat. Adopter: Akka, Cats, Scala.js minor version bump normally means binary incompatibility with the last point release)
  4. Common sources of binary incompatibilities
  5. Guidance on how to design library interfaces such that they are “evolvable”

Now I’m looking for some help on these areas:

  • Examples of binary incompatibility causes (no.4 above)
  • Tips on library design (no.5 above)
  • Any other topics you want me to cover

I’ll be updating the lists below as we go so keep them coming :slight_smile:

Causes of Binary Incompatibilities (while being source compatible)

(I think it’s also worth listing some theocratically source incompatible changes that are quite ‘resilient’ in practice AKA doesn’t cause compile errors)

  • Adding private val/vars to non-sealed traits
  • Adding new parameter with default values to methods
  • Inlined functions

Tips for library design

  • Avoid default parameters?
  • Modifying method signature - mark old method as package-private and define a new method of the same name
  • Don’t use the inliner
Bonus: Source incompat but binary compatible changes
  • add public method
  • change public to package private
  • converting method taking varargs vs Seq (both ways)

Cheers!

7 Likes

This is not the right thing to do, because it is useless. A much more useful version scheme is the following:

  • Bump minor version if the new release contains (likely) source incompatibilities
  • Bump major version if the new release contains binary incompatibilities

This versioning scheme assumes that binary incompatibilities are worse than source incompatibilities. Why is that? Because of the dependency diamond problem. Let me explain.

If you are the author of an application App, you don’t care about writing it with binary or source compatibility in mind, because no one depends on you.

If you are the author of a library A used by some applications Apps, binary compatibility is still irrelevant to you. If you publish a new version, then either an App upgrades and it will recompile with your new version, or it doesn’t upgrade. In no scenario will it compile with version x but link with version y. Of course, you want to care about source compatibility so that the Apps can easily upgrade to your new version, but it’s not a big deal if you break source compat. At the time of the upgrade, the App can adapt to your source incompatible changes. They can also not upgrade if they don’t want to.

Compatibility becomes critical if you are the author of a library X, used by several libraries A, B, etc., themselves used by applications Apps (or further libraries down the line). To make things concrete, consider the following scenario:

  • A version a1 depends on X version x1
  • B version b1 depends on X version x1
  • App depends on A v a1 and B v b1.

Now let’s say you publish a new version x2 of X. If this version is source incompatible with x1, but binary compatible, the following happens. A can upgrade to X version x2 and publish version a2. At this point, it might need to change its source code to account for the source incompat of X, but that’s fine. The important thing is that App can upgrade to A version a2. Now we have a funny thing: App depends on A a2 which depends on X x2, and App depends on B b1 which depends on X x1. Note that when upgrading App had to take into account source incompats of A, and possibly source incompats of X x2, assuming the source code of App directly references things from X. But the important thing is that it now has two versions of X on its linking classpath; of course x1 is evicted. That means that we link App, A a2, B b1 and X x2 together. Oh look! there’s B b1 which expected X x1 but it receives x2. That is not an issue, however, because X evolved in a binary compatible way (even if not source compatible).

What you need to see here, is that despite source incompatibilites, the path through A could perform upgrades, and App could depend on new versions of A and X, without B having to do anything.

Now what if the changes were binary incompatible. In that situation, we are stuck. App cannot upgrade A because of B! App is stuck with A a1 until a new release of B comes up that depends on X x2. What if, in the meantime, X published x3, and B’s maintainer decides to go straight to X x3?

In case of binary incompatibilities in the upper parts of the dependency graph can prevent from upgrading a branch of the graph without also upgrading unrelated other branches of the graph. This is dependency hell. Add a couple more libraries to the mix besides A and B, and App can never ever upgrade any of its libraries in a way that they all work together.

Source incompatibility does not have this unrelated branches issue. You can always upgrade any part of the graph, including App, to newer versions of parts of its dependency graph, without being impacted by other parts of your dependency graph.

This is a very, very important point. Binary compatibility is more important than source compatibility! That is because binary incompatibilities lead to dependency hell, while source incompatibilities do not.

Therefore, breaking binary compatibility should be something extreme that requires a major version bump. Breaking source compatibility is not that big a deal, and only deserves a minor version bump. From a technical point of view, this is the only versioning scheme that makes sense for Scala libraries.

8 Likes

Please include how macros and inlining affect binary compatibility.

1 Like

Thanks for the informative post. I have a few questions:

  • Do you have any example of source incompatible changes that are binary compatible? I wasn’t aware that this is a possibility.
  • Do you have any example of libraries in the Scala ecosystem that’s versioned this way? In the libraries that I’m familiar with, it seems like the versioning scheme is a bit over the place with most following the versioning scheme i mentioned (e.g. Scalaz or even Scala itself, if you know what I mean). While I agree with your technical points, I’m hoping to not confuse new users as they might assume two versions to be compatible when they’re not. It’ll be good if we can lay down some basic guidelines (along the lines of what you’ve said) and point to some libraries that follow this scheme.

@soronpo can you briefly describe how macros affect binary compat?

Sorry I wasn’t clear. I’m ignorant of binary compatibility, and I’m anxiously waiting for your guide now :slight_smile: . I just know that inlining affects binary compatibility, and I’m assuming macros do too.

Oh yes.

Varargs versus Seq

def foo(xs: Int*): Int = xs.sum

def foo(xs: Seq[Int]): Int = xs.sum

Source incompatible, but binary compatible. (both ways)

Change public to protected[Scope]

object Enclosing {
  def foo(x: Int): Int = x
}

object Enclosing {
  protected[Enclosing] def foo(x: Int): Int = x
}

Add a public method

final class Foo {
}

final class Foo {
  def bar(): String = "bar"
}

is source incompatible for a user that has

implicit class FooExt(private self: Foo) extends AnyVal {
  def bar(): String = "my bar"
}

def main(): Unit = {
  val foo: Foo = ???
  println(foo.bar())
}

Upgrading Foo without recompiling main preserves the old behavior of printing "my bar" (so binary compatible), but if main is recompiled, it now prints "bar" (source incompatible).

I could probably come up with other things, but those are the ones that immediately pop to my mind. The last one is particularly important to understand: it means that evolving a Scala library in a 100% source compatible way is virtually impossible. Maintaining binary compatibility is easier!

3 Likes

Scala.js is versioned that way. It’s not obvious right now because the main line is 0.6.x, but the 6 acts as major version here. We don’t break binary compatibility ever in the entire 0.6.x series. That means things compiled against 0.6.0 are still usable today in an ecosystem that spans 21 patch versions released over a span of 2.5 years (current is 0.6.20). Source compatibility has however been broken in very minor ways along the lines (most notably because new public methods, see my previous post).

Starting from 1.x it will be more obvious: 1.x.y will forever be backward binary compatible with 1.0.0, but minor releases might, potentially and pretty rarely, introduce source incompatibilities in edge cases (e.g., adding public methods, again).

1 Like

Maybe we should also be careful to specify whether we’re talking about backward compatibility alone or both ways compatibility. Or is forward compatibility considered irrelevant?

IMO forward compatibility is irrelevant. But if someone considers forward compat important, it is easy to derive the rules from those for backward compat: simply switch the “before” and “after” code :wink:

Sweet thanks! I think it’s worth having an (almost exhausive) list of these examples separate from the guide.

Akka is also using a very similar (if not the same) versioning system with top priority on Binary Compatibility. Further reading: http://doc.akka.io/docs/akka/current/scala/common/binary-compatibility-rules.html

2 Likes

Nice!

In addition, I would say that Scala itself also follows this versioning scheme, because in “2.12.3”, 12 is really the major version. 2 is more like the “epoch”.

1 Like

The guide we’re discussing is aimed at library authors, not at users. Library authors should be taught that binary compat is a major breaking change. You want to prescribe what library authors should do, not describe to users what library versions mean. The latter should be explained by each library author for their own libraries (possibly referring to a common document shared by many libraries).

Just to clarify and avoid any misunderstanding – the above Akka versioning doc explains that we moved away from the [epoch.major.minor] and use [major.minor.patch] nowadays. This means we’re more strict than before with maintaining binary compatibility – 2.5.x is backwards compatible, and actually even 2.4.x is backwards compatible to 2.3.x. In the previous scheme, before 2.3 these would be binary breaking.

Currently we strive to never* (unless huge change, but that’s many years ahead if ever) have to break compatibility and aim to remain in the 2.x major timeline, never breaking anyone’s code (see the doc for exceptions from the rule (may-change and deprecated APIs).

I like this initiative, and wanted to write such a thing at some point as well as we do some crazy things to maintain compatibility sometimes… I’ll try to chip in a few examples, no hard promise though…

Good idea, thanks for picking up the topic!

– Konrad

I definitely do agree with what Akka and and Scala.js project is doing. Currently when a transitive library is evicted with a minor version bump, I have to dig deeper into these project’s release notes to find out whether I need to worry about binary incompatibilities. With guidance on how libraries are versioned, hopefully we’ll get to a point where a glance at the evicted versions will tell us whether we need to worry.

I would like to chime in to say that I believe Sébastien’s proposal is the best way to version Scala libraries and that we should make it an standard. I proposed it for typelevel/cats, but nobody seemed excited about it: https://github.com/typelevel/cats/issues/1233#issuecomment-320770859.

To me it seems that the decision was precisely to use this versioning scheme. It’s even slightly stricter, although only in a hand-wavy way, in that backward source breaking changes should be avoided as much as possible. So even better!

So @jatcwang that’s another big Scala library using this system: cats.

Also relevant is how sbt interpret version numbers, for Scala library the scheme used to check for BC is https://github.com/sbt/librarymanagement/blob/480a1a366ddd69e4179fb097eb84163a1ffbf42f/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala#L135-L138, meaning that Scala.js 1.0.0 and 1.1.0 would not be considered binary compatible unfortunately.

Honestly, sbt’s auto detection of what it thinks Scala libraries consider as BC is just a nuisance. No offense, but sbt maintainers don’t see all the complaints that we get from people who are worried about sbt compatibility warnings about my libraries. I have to constantly tell people to ignore sbt eviction warnings. It’s really a pain.

I’ve mentioned that a couple of times already. I wish sbt just stopped with that, or at least took the conservative assumption (which is semver, i.e., the first number can break bin compat, nothing else).

Maybe what’s needed is some way for libraries to let sbt know what versioning scheme they’re using, e.g. with a field in .pom files? What do you think @eed3si9n ?