The case for code compatibility

This is an attempt on summing up the arguments for code compatibility. If it’s not very evenly balanced, it might be due to my convictions of code compatibility being the better alternative.

Definition of code compatibility

We say that A is code compatible with B if valid use of code generated from B also is valid use of code generated from A with the same sound compiler.

Restrictions

Restrictions on DSDL

For a DSDL definition:

  • All variable names that exists in B must also exist in A. They must also have the same type.
  • All const names that exists in B must also exist in A. They must also have the same type.

Restrictions on DSDL compilers

For a compiler implementation to be sound, it must generate compatible code if:

  • All variable names that exists in B also exist in A with the same type.
  • All const names that exists in B alsos exist in A with the same type.

The argument for code compatibility

Automatic update to the newest version of a definition is nice.

With code compatibility, no work is required when updating to new definitions with the same major version number. This is the non-surprising behavior and allows developers using new fields without extra maintenance burden.

Not having code compatibility doesn’t give us much

The benefits of not having code compatibility are mainly the ability to fix misspelled words and doing small ergonomic changes as unrolling arrays and the like. The problem of doing ergonomic changes is that breaking the code to get the corrected spelling is more unergonomic than living with the misspelled word until the next major version release.

Having minor version updates solves all other problems.

Most (all?) of the examples where code compatibility would be useful for solving the problem. Incrementing minor version and populating void field would be a better solution. This will require some caution when stabilizing types, but should be achievable.

The changes that break code compatibility often make dangerous assumptions

One example that was brought up was removing the sub_mode field from NodeStatus. The problem is that removing a field from a definitions is a very dangerous thing to do. If someone has started using it and it gets repopulated everything can quickly becomes quite messy.

To avoid breaking fielded nodes we should avoid doing potentially dangerous changes that breaks code compatibility.

The argument against code compatibility

Adding new features to DSDL

If we add new features/types to DSDL at a later point we will not be able to use them in a backward compatible way if requiring code compatibility. An example is that if we create a type Q16_8:

uint16 integer_bits
uint8 fractional_bits

We will not be able to substitute it for a native q16.8 type later on


For the full (incredible long) discussion of code compatibility check out this github issue: https://github.com/UAVCAN/specification/issues/9

Is it fair to summarize this as a debate about the definition of minor revision changes?

That is:

Kjetil

All minor versions within a given major version are code compatible. Any code-compatibility-breaking change would require a bump of the protocol’s major version.

Pavel

Code-compatibility is not guaranteed between any two version of the protocol but any code-compatibility-breaking change would require a bump of the protocol’s patch version (i.e. if the version doesn’t change then code-compatibility must be maintained).

1 Like

So my fear is that this constraint drives changes to the major version of the protocol whereas I would like to see major version changes be few and far-between.

What if we said that code compatibility must be maintained for a given major.minor version and that the minor version would be incremented if code compatibility was violated but that the minor version might change without breaking code-compatibility? So, for example, 1.1 is not necessarily code-compatible with 1.2 but it might be where as all changes to 1.1 maintain code-compatibility. Of course this proposal is meaningless if we don’t define what changes are allowed within a minor version (I’m not sure we discuss this at all in the current draft of the specification). So if we do allow some changes within a given minor version then it follows that we should add a third version level that is incremented for any change (e.g. 1.0.0 -> 1.0.1 -> 1.1.0).

Certainly other protocols do not discuss nor maintain this property but I think it would speak to the UAVCAN awareness of the effect the specification has on possible implementations to constrain code-compatibility within a minor version of the protocol and would encourage manufacturers to accept patch versions more readily.

I am wondering if the versioning practices described here were devised in a wrong mindset. I would argue that protocols are made to be versioned differently compared to applications that use them, because the impact of a version switch in a protocol is not local to the application that made the switch, but affects all other participants on the bus. Therefore, to keep the bus maintainable, we must strive to make a major version update as rare as possible, making it a truly last resort option. Compared to the profound effect that a protocol version update has on the entire bus, the possibility of breaking a code compatibility on a particular single node seems like an insignificant nuisance for the developer.

The versioning approaches described above seem to be centered on the application, as if we were evaluating the bus in the scope of a local node. I would argue that this approach is unnecessarily low-level and offers a rather limited perspective, as it disregards the bigger picture of inter-node compatibility; rather, we should view the bus on the level of inter-node data streams, delegating the lower level issues, such as that of the code compatibility, to levels of abstraction that the specification does not concern itself with.

Even if we were to disregard the above, I would like to refute the specific arguments given above: “Automatic update to the newest version of a definition is nice” and “Not having code compatibility doesn’t give us much”. While nice, the code compatibility guarantee would also limit the scope of minor version changes to the point where it wouldn’t make much difference which particular minor version of a definition is used, since they all will be more or less functionally identical. The specific example where a new field is added in a code-compatible way does not seem to work well because in order to make use of the new field, one would have to alter the code, thus negating the advantages of keeping its compatibility.

I still don’t think that adding any additional constraints besides the already existing two (bit compatibility and semantic compatibility) would benefit the protocol.

Yesterday at the call we reached a consensus with Kjetil that code compatibility can be broken under the same major version to avoid the issues I and Scott talked about here earlier. He urged me to have a closer look, however, at the Scott’s proposal to introduce a third version number to handle code compatibility under the same minor version.

I think all three of us agree on this part now; please correct me if I’m mistaken:

  • Same major version guarantees bit compatibility and semantic compatibility.
  • Same minor version guarantees code compatibility (plus all guarantees of the same major version, obviously).

This is not to say that “code compatibility” needs to be mentioned in the specification at all – see below.

We do discuss the extent of possible changes to a minor version. Here’s a relevant quote from the latest draft (although the concept of “minor version” is not explicitly mentioned in this excerpt, it is implied):

In order to ensure predictable and repeatable behavior of applications that leverage UAVCAN, the standard requires that once a data type definition is released, it cannot undergo any modifications to its attributes or directives anymore. Essentially, released data type definitions are to be considered immutable excepting comments and whitespace formatting.

Which means that it’s okay to change formatting or to update comments, but that’s it. One can’t populate a void space with a new field, for example, or rename an attribute. This requirement obviates the need to introduce “code compatibility” to the spec.

The question of patch numbers was discussed a year ago on GitHub:

I understand the value of semantic versioning in the context of software versioning, but it is not evident to me why is it required to have the explicit patch version in DSDL. The information conveyed by the patch version is equally accessible via version control systems. I propose to remove the patch version from the RFC and keep only those that affect the semantics and/or the binary layout of data types: major and minor.

Presence of multiple definitions differing only by their patch version number might make namespaces harder to maintain and use because of a large number of virtually identical definitions. Among definitions under the same minor version only the latest patch version would be used (relying on older versions would make a very limited sense since they are all code-compatible). Also, the patch version would require introduction of a new concept into the specification, which is always undesirable.

I suggest that we keep major and minor version numbers only, allowing cosmetic changes to minor versions (whitespace & comments), and introducing a recommendation to the specification that DSDL definitions should be version-controlled to substitute for the lack of a patch number.

1 Like

:+1: (this forum needs Slack-style reactions)

1 Like

Using version control for dependency/package control is an anti-pattern we should avoid to rely on. Just because you’re pretty much stuck with using submodules for deps management in C/C++ doesn’t mean it’s a good idea to require languages with sensible package management to do the same. Functional changes needs to be reflected in the version so dsdl have a chance to be distributed in more modern ways than git submodules at a later point.

I think it would be OK to remove (git rm) older definitions with equal patch versions. They can be reconstructed from running through the git history anyway.

Even if several versions with the same patch version is present. DSDL compilers should by default only compile the newest definitions.


I suggest that we avoid demanding code compatibility for updates to minor version, but keep it as a concept and enforce it for patch version changes.

Totally agree. That doesn’t seem to be against the approach I described earlier though because there can be no functional changes within the same minor version. Whitespaces and comments cannot affect the functionality, binary layout, or code compatibility of a data type definition. Does the ability to select between several functionally identical definitions that look exactly the same to a machine worth the addition of a third version number?

I am interested to know your opinion about the requirement that only whitespaces and comments can be changed under the same minor version. If this requirement is accepted, there will be no need to introduce code compatibility, since it will be implicitly maintained.

Knowing that changes can happen without it being expressed in the versioning system makes me a bit uneasy. I’ve accepted that whitespace/comment changes are allowed to happen if we have a tool verifying that it is in fact only comment/whitespace changes. But I honestly think it would be better to have a patch version that we bump even for these kinds of things. Introducing code compatibility for patch version would also make it a bit more useful and I, therefore, think this is the smart thing to do.

One of the most difficult things about programming is managing complexity, controlling (and being explicit about) mutability is an excellent tool for doing this as it becomes much easier to mentally reason about things if changes are obvious (expressed in some way).

I think disallowing mutation of types (without bumping some version) will allow users to spot changes quickly and feel more confident in using the definitions. I, at least, know I would this way about using the library if I didn’t follow (and partake in) development as closely as I am.

I think allowing code compatible changes in patch versions will allow us to bump minor versions at a slower rate, again allowing users to use the most updated versions of the definitions without any changes to their code (as switching to a newer patch version is automatic unless locked to a specific git hash or deps management lock-file).

I guess we will continue defining a type dependency only with the major and minor version. This means that as a bonus you can populate a void field of a type and all definitions using it will be usable with the newest version of the type without any changes (as only the patch version of the included type is changed).

I see a many small improvements of the approach i describe, but fewer and smaller disadvantages. I don’t think the way i descibe it is in essential for the greatness of uavcan, but I definitely think it’s one of those nice small things that helps.

I can totally see that and I agree in essence. But even if you made definitions completely immutable per patch version, there is still some degree of variance present at a bigger scope.

One thing that comes to mind (albeit this is a rather unusual aspect unless we’re talking about high design assurance levels) is that the resulting program is not just a function of the compiler inputs, unless the compiler is formally validated (there are very few of those) and the hardware that executes it is fault-free (e.g., an undetected bit flip in DRAM may produce an invalid binary – I wonder if there are any recorded cases of that). Honestly, I am not sure how relevant that is, though, so we can just skip this argument. Another thing that comes to mind is that there is some uncertainty about nested types unless each nested type is specified explicitly down to the patch version, which we don’t want to do because it makes type maintenance a nightmare. Consider this: a type Foo is defined as follows:

Bar.1 walks_into

Then suppose that we have definitions Bar.1.0.8 and Bar.1.1.1. Upon parsing, the DSDL parser would pick Bar.1.1.1 as this is the newest bit- and semantically-compatible version. Then imagine that a new definition Bar.1.2.0 is added, and Foo is recompiled. The new definition of Foo would switch to a dependency under a new minor version, while still being under the same patch version itself.

The automatic low version selection feature that we introduced earlier makes pinning down type versions unsuitable for the provision of any compatibility guarantees excepting bit- and semantic-compatibility. And this, I think, is good, as those are the only things the protocol should be concerned about – we talked about this here earlier.

If there is a need for explicit version management outside of version control systems, perhaps we should look into versioning of root namespaces instead? In a way that can be handled outside of the scope of DSDL compiler, probably?

It’s about managing complexity, not formally proving that an error can never happen. An error can happen and probably will. But when errors happen, they will be easier to find and fix if there is one less thing that needs to be considered. The argument also doesn’t make much sense as it also could be used against every good design principle on the basis that you do not control every variable.

The Rust std lib had memory bugs for a long time, these types of bugs doesn’t invalidate all the memory safety checking at compile time, or makes rust something else than an efficient tool for avoiding memory related bug in almost every case.

Even if we allowed such definition within DSDL, (and we probably should not) in our current world it would be a bad idea unless you enjoy having your code break without explanation. It could be solvable by introducing mandatory lock-files for the compilers, but I don’t think this is the best idea either.

The only version that would make sense to to be resolved automatically is the patch version. This is safe as all sorts of compatibility applies (even code compatibility). Allowing automatic resolution of minor version would be as bad as allowing code breaking changes without changing any sort of version, which is bad.

On the plus side, leaving patch type unspecified together with allowing code compatible changes for patch version will allow void fields to be populated without needing to change the version of the type depending on the updated type. All this in a way where versions can be checked.

We already have the explicit version management outside version control. it’s about tagging all types with a version and checking that some properties hold. The DSDL compiler doesn’t need to do much either. This is what we need and what we will have. Attempting to version the whole root namespace is still a worse solution.

I agree that the current approach of automatic selection of the newest minor version is flawed. I was quite sure that we discussed this earlier and seemingly reached an agreement, but I can’t find that discussion now. Could you please change that in the DSDL chapter, seeing as you are currently working on it? I will update PyDSDL to always require both versions.

Rest of the argument still doesn’t make much sense to me.

Allowing automatic patch version selection for nested types would defeat the purpose of the patch version for the enclosing type. The problem I talked about in my previous message when a new data type with a newer minor version is introduced still stands, except that instead of the minor version it would affect the patch version.

The example of Cargo/PyPI does not seem to contradict the current arrangement. When you, say, modify a source file for your program, you don’t keep the old version around in the next git commit. Instead, it gets overwritten, and the older version will be stored in the git log. When a release-worthy state is reached, the released version is tagged and uploaded into a package repository like Cargo/PyPI, where it remains accessible forever. Same thing is attainable with DSDL at the namespace level, along with the possibility of using an external package repository, if desired.

It is true that there is some argument to be made against versioning of entire root namespaces as opposed to individual types within them: this approach couples versioning processes of possibly unrelated types, requiring the application designer to pay attention to all used types with every update, even those that did not require it. With the strong guarantees provided by the minor version, however, the amount of extra workload will be negligible, so I don’t consider this to be an issue.


My counterexample was poorly thought out, sorry. What I wanted to imply is that the additional guarantees provided by the patch version would be as weak as the risks introduced by non-validated compilers or bit-flipping DRAM. The memory bugs in Rust do not seem to be relevant here, because the Rust safety guarantees are built on top of a strong framework of enforceable rules and principles, whereas in the case of DSDL versioning, you have to trust that the existing definitions are maintained in a particular way, the only change introduced by the patch version is that the “particular way” is changed from “allowing only whitespace and comment changes” to “allowing no changes at all”; the base foundation (the source version control system) remains the same.


I understand the argument in favor of immutability, but I can’t see how strong per-data-type immutability guarantees, as opposed to per-namespace immutability guarantees, are helpful. Data types can have implicit inter-dependencies and coupling that cannot be expressed in terms of DSDL (at least, not currently). There can be micro-protocols built on top of several types that have no DSDL dependencies between each other, and hence the versioning system would not know about them; for example, consider the types defined under uavcan.file, uavcan.internet.udp, or uavcan.pnp. Locking down one type down to perfect immutability while possibly allowing others to differ or new ones to appear may somewhat undermine the usefulness of the guarantees, requiring the developer to re-evaluate the entirety of the namespace every time any member of it is changed.

Code compatibility make sure nothing will ever break. The minor version chosen is check-able, meaning the versions work as a way to track that you’re using the exact same code today as yesterday.

This is close to the way Cargo (Rust package manager) does it by default, it always assumes semver and pull in the newest compatible version when doing a fresh compile. It then adds a lockfile so repeating compilations of the same project will not result in different code (unless cargo update is used). This is an excellent system.

Minor version for the enclosing type will be useful as doing code compatible changes will require a bump somewhere, and if it’s in the minor version it can be upgraded automatically in types depending on it.

I’m not 100% sure what you mean by the current arrangment so I will try to answer broadly.

I wish we could use git only for development and not for distributing the definitions. But If the git repo is to be used directly as a submodule for C/C++ projects we, unfortunately, need to keep all minor versions in there. This is due to the minor versions not being automatically updatable (since they can break code) and a need to pull in a newer git hash (due to some upgrade in an unrelated type that is required) could break the code if the minor version was replaced instead of added.

If we let the DSDL repo represent the latest releases we must have mutability between versions. We cannot release the same version twice in dependency management systems so there must only be one version.

I thought we had accepted to version types instead of root namespaces a long time ago?

To keep the version of everything tied together is not sustainable with the DSDL language mechanisms and level of complexity compared to development time we currently have.

If we want to give the version root namespace approach another attempt we need to do the following.

  • Code compatibility as a concept. If you need to bump the version to obtain type X this should absolutely not be allowed to break type Y.
  • after some @unstable directive is removed, only code compatible changes can happen.
  • We lose a lot of possibility to do changes in a controlled way, this means that we cannot do these changes.

By making them immutable, we’re not enforcing that everyone uses them forever. You just have to bump some version number to be able to change them. This is somewhat a big deal, but not that big of a deal either.

Your argument of micro protocols worries be a bit more, this might actually make seperate versioning of types unsound, let me think a bit more of that.

I fear we’re not on the same page, but a practical example should help us sync. Consider namespace.OuterType:

namespace.NestedType.1.2 field

Then we have several definitions for namespace.NestedType: version 1.2.3 and version 1.2.4. Per your suggestion above, the parser would pick NestedType version 1.2.4. Cool.

Now, at some point, we introduced a very minor fix to NestedType and published version 1.2.5. The OuterType now refers to version 1.2.5, even though its own patch version has not been changed. This is a serious problem as I explained above.

I am not suggesting to replace minor versions. They should be kept around in the version control system until the whole major version is removed (e.g., due to deprecation). My objection was related to patch versions only.

Yes. I think I was unclear; I am proposing a classic semver mechanism on top of namespaces. Suppose we designed a bunch of data types, each has a major+minor version number (no patch). At one point we decided that the resulting type set is stable enough for a release, so we release them all together and assign some version number to the resulting collection. This is what I call “root namesace versioning”; although each data type in the released namespace has version numbers as well (only major + minor). As an analogy, when you are releasing a class library, you don’t version each class separately, do you? Instead, you assign a version number to the whole collection, to the whole library as an atomic structure. Also see my note about micro-protocols.