Skip to content

Breaking Changes

Nicolas Pepin-Perreault edited this page Aug 30, 2024 · 6 revisions

The following sections describe how backwards compatibility is maintained in our Java and Go public API, to what extent it is maintained and what you must do if you want to break backwards compatibility.

Java

We have several modules that comprise our public API in Java. Breaking changes in any of the modules will be detected by the Revapi plugin and fail the build. If these changes are intentional, then you can make revapi ignore them by adding the desired ignored change in the specific module's ignored-changes.json file. You can find this file in any of these module's root directory - if it does not exist it, simply create it yourself. The following is an example of what it can look like:

[
  {
    "extension": "revapi.differences",
    "id": "differences",
    "configuration": {
      "differences": [
        {
          "justification": "We added a new value, DEAD, which changed the ordinals; however, SBE enums do not/should not rely on ordinals, but rather on the defined value.",
          "code": "java.field.enumConstantOrderChanged",
          "classQualifiedName": "io.camunda.zeebe.protocol.record.PartitionHealthStatus",
          "fieldName": "NULL_VAL",
          "oldOrdinal": "3",
          "newOrdinal": "4"
        }
      ]
    }
  }
]

This would ignore the addition of a new enum field in a generated enum. Please make sure when ignoring changes to always provide a justification.

At each release, the ignored-changes.json files will be deleted and the version against which revapi compares the current state will be updated to the release version. If, for some reason, you wish to change the name of the ignored changes JSON file, you must change the ignored.changes.file property in parent/pom.xml but not in the release scripts.

Failure handling

Revapi performs semantic analysis of the archives under test and compares them with a specific previous version. It will detect not only semantically breaking changes, but also unexpected changes which may lead to breaking usages, such as leaking internal/private types via your public APIs. Normally, when an error occurs, it will produce the JSON you need should you wish to ignore this change. You can then add this to the ignored change's differences extension. Note that it only produces the differences themselves, not the extension wrapper, so you will need to wrap it yourself if the file does not exist yet.

For example, on failure, it may output:

        {
          "code": "java.field.enumConstantOrderChanged",
          "classQualifiedName": "io.camunda.zeebe.protocol.record.PartitionHealthStatus",
          "fieldName": "NULL_VAL",
          "oldOrdinal": "3",
          "newOrdinal": "4"
        },

You cannot simply create a JSON file with only this content. You will need to wrap as shown in the example above.

Writing arbitrary rules

You can find an explanation of the differences extension of Revapi here, and of the filter extension here. These are the extensions you will most likely use.

You can find here a list of all the possible differences, here a longer explanation on how to do semantic matching, and finally here documentation on their DSL to do structural search, i.e. what they call classif.

Go

In the Go client, every exported symbol in a non-internal package is a part of our public API. It's worth mentioning that Zeebe does not forbid every type of breaking change in its Go code. The reasoning for this can be found here but for the purposes of this wiki suffice it to say that we try to provide the Go1 compatibility guarantee. Notably, this allows adding fields to structs and method to types.

Breaking changes are detected with the gocompat tool which also allows you to easily ignore changes that fall into the Go1 compatibility guarantee. To use it, you can run the following command in the clients/go directory:

gocompat compare --go1compat ./...

The previous command will compare the current set of exported symbols against the .gocompat.json file present in clients/go. This file is re-generated during every release and whenever someone wants to merge backwards-incompatible API changes. You can regenerate it by running the following command in the clients/go directory:

gocompat save ./...

Protobuf

We use the buf CLI to guarantee both source and binary compatibility of our protocol buffer files. On each build, if there are changes a .proto file in the repository, we run the bufbuild/buf-action action.

To avoid building the complete repository (for example, certain vendored folders contain .proto files), at the root of the project, there is a buf.yaml configuration file (see here for a complete reference). There we define the modules which we want to build.

Note

This means if you add a new module with Protobuf files you want to check, you need to add it to the buf.yaml file, and define its compatibility rules.

When there are changes to a .proto file, we will run buf to detect breaking changes. The comparison is done based on the type of workflow trigger:

  • If it's a PR, the changes are compared against the base branch of the PR.
  • If it's a merge queue, the changes are compared against the target merge branch.
  • If it's a push, the changes are compared against the previous commit.

If you want to test locally that your changes are backwards compatible, you can install buf locally, or you can use Docker:

> buf breaking --against '.git#branch=main'
> docker run -v "$(pwd):/workspace" --workdir "/workspace" bufbuild/buf breaking --against '.git#branch=main'

If there are breaking changes, you'll see an error like this:

zeebe/dynamic-config/src/main/resources/proto/topology.proto:12:1:Previously present field "1" with name "version" on message "ClusterTopology" was deleted without reserving the number "1".

Bypassing the check

If you wish to allow some breaking changes in a PR, and you are sure that this is acceptable, then make sure your PR body contains the following: BREAKING CHANGE: <justification>. For example:

This is my super PR. There are many like it, but this one is mine.

BREAKING CHANGE: I remove previously deprecated calls.

How it works is that, for a PR, the check is skipped if the PR body contains BREAKING CHANGE: anywhere in it. And for merge queues/push, since the PR body ends up being the merge commit message, the check is skipped if the newest commit message contains BREAKING CHANGE: in it.

Note that in PRs, while the check always fetches the latest PR body, the CI job is not triggered when you edit your PR body, so you would have to manually re-run the failed job after adding this to the PR body.