| # Edition Lifetimes |
| |
| Now that Edition Zero is complete, we need to re-evaluate what the lifetimes of |
| features and editions look like going forward. |
| |
| ## Background |
| |
| The implementation of editions today was based largely on |
| [Protobuf Editions Design: Features](protobuf-editions-design-features.md) and |
| [Life of an Edition](life-of-an-edition.md) (among other less-relevant docs). |
| Specifically, the latter one takes a strong stance on the lifetimes of both |
| editions and features. Many of the ideas around editions have since been |
| simplified in [Edition Naming](edition-naming.md), where we opted for a stricter |
| naming scheme owned and defined by us. In the process of rolling out editions to |
| various protoc plugins and planning for edition 2024, it's become clear that we |
| may need to re-evaluate the feature lifetimes as well. |
| |
| *Editions: Life of a Feature* (not available externally) is an alternate vision |
| to *Life of an Edition*, which tries to put tighter constraints on how features |
| and editions interact. It also predicted many of the problems we face now, and |
| proposes a possible solution. |
| |
| ## Overview |
| |
| Today, features and editions are largely disconnected from each other. We have a |
| set of features that govern various behaviors, and they can all be used in any |
| edition. Each edition is simply a distinct set of defaults for **every** |
| feature. Users can override the value of any released feature in any edition. |
| Each generator and protoc itself all advertise a range of editions they support, |
| and will reject any protos encountered outside that range. |
| |
| This system does have the nice consequence that behavior-preserving editions |
| upgrades can always be performed (with respect to the most current proto |
| language). It separates editions from breaking changes, and means that we only |
| need to worry about one versioning scheme (our OSS release). |
| |
| While this all works fine in Edition 2023 where we only have a single edition, |
| it poses a number of problems going forward. There are three relevant events in |
| the lifetime of both editions and features, introduction, deprecation, and |
| removal. Deprecation is essentially just a soft removal to give users adequate |
| warning, and the same problems apply to both. |
| |
| ### Introducing an Edition |
| |
| Because every generator plugin (and protoc) advertises its edition support |
| window, introducing a new Edition is well-handled today. We get to enjoy all the |
| same benefits we saw rolling out Edition 2023 in every subsequent edition (e.g. |
| we can make radical language changes gated on edition). |
| |
| ### Dropping an Edition |
| |
| Dropping support for an edition doesn't really mean that much today. We *could* |
| do it simply by bumping up the minimum supported edition of a binary in a |
| breaking release. However, that would have no relation to our feature support, |
| and at best would allow us to clean up some parser code branching on editions. |
| This code would always be tied to language changes we made in the introduction |
| of a new edition, where we could finalize them with the removal of an edition. |
| |
| ### Introducing a Feature |
| |
| Whenever we introduce a new feature, we need to make sure to specify its |
| defaults for **every** edition (protoc enforces that every edition has a known |
| set of defaults). It will also immediately be overridable in every edition. This |
| means that pre-existing binaries that have declared support for an old edition |
| may suddenly be presented with protos that override a feature they can't |
| possibly know about. |
| |
| We faced this problem when introducing the new `string_type` feature as part of |
| *New String APIs* (not available externally). Our solution at the time was to |
| create some ad hoc validation that prohibited overriding this feature in Edition |
| 2023 until we were ready to release it. This solution doesn't work in general |
| though, where in OSS we can have arbitrarily old binaries floating around (and |
| even in google3 we can easily have up to six-month-old binaries within the build |
| horizon). These old binaries wouldn't have that validation layer, and would |
| happily process Edition 2023 files with `string_type` overrides, despite not |
| knowing how to properly treat that feature. |
| |
| ### Dropping a Feature |
| |
| On the other end of the spectrum, we need a way to deprecate and then remove |
| support for features. Given that we expect most features to remain for many |
| years, we haven't been forced to consider this situation too much. The current |
| plan of record though, is that we would do this by first marking the feature |
| field definition `deprecated`, and then remove it entirely in a breaking release |
| (and total burndown in google3). |
| |
| The problem with this plan is that it creates a lot of complexity for users |
| trying to understand our support guarantees. They'll need to track the lifetime |
| of **every** feature they use, and also difficult-to-predict interactions |
| between different versions of protoc and its plugins. If we drop a global |
| feature in protoc, some plugins may still expect to see that feature and become |
| broken, while others may not care and still work. |
| |
| ## Recommendation |
| |
| ### Feature Lifetimes |
| |
| We recommend adding four new field options to be used in feature specifications: |
| `edition_introduced`, `edition_deprecated`, `deprecation_warning`, and |
| `edition_removed`. This will allow every feature to specify the edition it was |
| introduced in, the edition it became deprecated in, when we expect to remove it |
| (deprecation warnings), and the edition it actually becomes removed in. |
| |
| We will also add a new special edition `EDITION_LEGACY`, to act as a placeholder |
| for "infinite past". For editions earlier than `edition_introduced`, the default |
| assigned to `EDITION_LEGACY` will be assigned and should always signal the *noop |
| behavior that predated the feature*. Proto files will not be allowed to override |
| this feature without upgrading to a newer edition. Deprecated features can get |
| special treatment beyond the regular `deprecated` option, and a custom warning |
| signaling that they should be migrated off of. For editions later than |
| `edition_removed`, the last edition default will continue to stay in place, but |
| overrides will be disallowed in proto files. |
| |
| For example, a hypothetical feature might look like: |
| |
| ``` |
| optional FeatureType do_something = 2 [ |
| retention = RETENTION_RUNTIME, |
| targets = TARGET_TYPE_FIELD, |
| targets = TARGET_TYPE_FILE, |
| feature_support { |
| edition_introduced = EDITION_2023, |
| edition_deprecated = EDITION_2025, |
| deprecation_warning = "Feature do_something will be removed in edition 2027", |
| edition_removed = EDITION_2027, |
| } |
| edition_defaults = { edition: EDITION_LEGACY, value: "LEGACY" } |
| edition_defaults = { edition: EDITION_2023, value: "INTERMEDIATE" } |
| edition_defaults = { edition: EDITION_2024, value: "FUTURE" } |
| ]; |
| ``` |
| |
| Before edition 2023, this feature would always get a default of `LEGACY`, and |
| proto files would be prohibited from overriding it. In edition 2023, the default |
| would change to `INTERMEDIATE` and users could override it to the old default or |
| the future behavior. In edition 2024 the default would change again to `FUTURE`, |
| and in edition 2025 any overrides of that would start emitting warnings. In |
| edition 2027 we would prohibit overriding this feature, and the behavior would |
| always be `FUTURE`. |
| |
| ### Edition Lifetimes |
| |
| By tying feature lifetimes to specific editions, it gives editions a lot more |
| meaning. We will still limit this to breaking releases, but it means that |
| **all** of the editions-related breaking changes come from this process. When we |
| drop an edition, breaking changes will always come from the removal of |
| previously deprecated features. By regularly dropping support for editions, we |
| will be able to gradually clean up our codebase. |
| |
| #### Edition Upgrades |
| |
| A consequence of this design is that edition upgrades could now become |
| potentially breaking. Any proto files using deprecated features could be broken |
| by bumping its edition to one where the feature has been removed. Within |
| google3, we would need to completely burn down all deprecated uses before we can |
| remove the feature. |
| |
| This is not a substantial change on our end from the existing situation though, |
| where we'd still need to remove all uses before removing it. The key difference |
| is that we have the *option* to allowlist some people to stay on an older |
| edition while still moving the rest of google3 forward. We would also be able to |
| continue testing removed features by allow-listing dedicated tests to stay on |
| old editions. |
| |
| #### Garbage Collection |
| |
| Another consequence of this is that we can't actually clean up feature-related |
| code until every edition before its `edition_removed` declaration has been |
| dropped. This ties feature support directly to edition support, especially in |
| OSS where we can't forcibly upgrade protos to the latest edition. |
| |
| #### Predictability |
| |
| The main win with this strategy is that it clarifies our guarantees and makes |
| our library more predictable. We can guarantee that a proto file at a specific |
| edition will not see any behavioral changes unless we: |
| |
| 1. Make a breaking change outside the editions framework. |
| 2. Drop the edition the proto file uses. |
| |
| We can also guarantee that as long as users stay away from deprecated features, |
| they will still be able to upgrade to the next edition without any changes. |
| |
| ### Implementation |
| |
| Fortunately, this design would be **very** easy to implement right now. We |
| simply need to add the new field options and the new placeholder edition, and |
| then implement new validation in protoc. Because the two error conditions (using |
| a feature outside its existence window) and the warning (using a deprecated |
| feature) only trigger on *overridden* features, protoc already has all the |
| information it needs. Generator feature extensions must be imported to be |
| overridden, so the problem of protoc not knowing feature defaults doesn't come |
| into play at all. |
| |
| If we wait until edition 2024 has been released, the situation would be a bit |
| more difficult to unravel. Any new features added in 2024 would be usable from |
| 2023, so we'd have to either intentionally backport support or remove all of |
| those uses before enabling the validation layer. Therefore, the recommendation |
| is to implement this ASAP, before we start rolling out 2024. |
| |
| #### Runtimes with Dynamic Messages |
| |
| None of the generators where editions have already been rolled out require any |
| changes. We likely will want to add validation layers to runtimes that support |
| dynamic messages though, to make sure there are no invalid descriptors floating |
| around. Since they all have access to protoc's compiled defaults IR, we can pack |
| as much information in there as possible to minimize duplication. Specifically, |
| we will add two new `FeatureSet` fields to `FeatureSetEditionDefault` in |
| addition to the existing `features` field. |
| |
| * overridable_features - The default values that users **are** allowed to |
| override in a given edition |
| * fixed_features - The default values that users **are not** allowed to |
| override in a given edition |
| |
| We will keep the existing `features` field as a migration tool, to avoid |
| breaking plugins and runtimes that already use it to calculate defaults. We can |
| strip it from OSS prior to the 27.0 release though, and remove it once everyone |
| has been migrated. |
| |
| In order to calculate the full defaults of any edition, each language will |
| simply need to merge the two `FeatureSet` objects. The advantage to splitting |
| them means that we can fairly easily implement validation checks in every |
| language that needs it for dynamic messages. The algorithm is as follows, for |
| some incoming unresolved `FeatureSet` user_features: |
| |
| 1. Strip all unknown fields from user_features |
| 2. Strip all extensions from user_features that the runtime doesn't handle |
| 3. merged_features := user_features.Merge(overridable_defaults) |
| 4. assert merged_features == overridable_defaults |
| |
| This will work as long as every feature is a scalar value (making merge a simple |
| override). We already ban oneof and repeated features, and we plan to ban |
| message features before the OSS release. |
| |
| Note, that there is a slight gap here in that we perform no validation for |
| features owned by *other* languages. Dynamic messages in language A will naively |
| be allowed to specify whatever language B features they want. This isn't |
| optimal, but it is in line with our current situation where validation of |
| dynamic messages is substantially more permissive than descriptors processed by |
| protoc. |
| |
| On the other hand, owners of language A will have the *option* of easily adding |
| validation for language B's features, without having to reimplement the |
| reflective inspection of imports that protoc does. This can be done by simply |
| adding those features to the compilation of the defaults IR, and then not |
| stripping those extensions during validation. This will have the effect of tying |
| the edition support window of A to that of B though, and A won't be able to |
| extend its maximum edition until B does (at least for dynamic messages). For |
| generators in a monorepo like Protobuf's this seems fine, but may not be |
| desirable elsewhere. |
| |
| ### Patching Old Editions |
| |
| In [Edition Naming](edition-naming.md) we decided to drop the idea of "patch" |
| editions, because editions were always forward and backward compatible. We would |
| only ever need multiple editions in a year if somehow we managed to speed up the |
| rollout process and wanted faster turnaround. This changes those assumptions |
| though, since now editions are neither forward-compatible (new features don't |
| work in old editions) or backward-compatible (old features may not work in new |
| editions). |
| |
| Hypothetically, if there were a bug in the editions layer itself we may require |
| a "patch" edition to safely roll out a fix. For example, imagine we discover |
| that our calculation of edition defaults is broken in edition 2023 and we had |
| accidentally released it. If we've already fixed the issue and released edition |
| 2024 as well, we can't just create a `2023A` "patch" to fix the issue because |
| editions are represented as integers (and 2023 and 2024 are adjacent). We would |
| want to release some kind of fix for people still on edition 2023 though, so |
| that they can minimally upgrade before 2024 (which may be a breaking edition). |
| |
| What we could do in this situation (if it ever arises) is introduce a new |
| integer field in `FileDescriptorProto` called `edition_patch`. It would take |
| some work to fit this into feature resolution and roll it out to every plugin, |
| but given that we've hidden the edition from most users |
| ([Editions Feature Visibility](editions-feature-visibility.md)) it shouldn't be |
| too bad. As long as patches never introduce or remove features or change their |
| defaults, protoc and plugins can always use the latest patch they know about to |
| represent that edition. |
| |
| ### Documentation |
| |
| As part of this change, we need to document all of this publicly for |
| plugin/runtime owners. We should create a new topic in |
| https://protobuf.dev/editions/ to cover all of this, along with other relevant |
| details they'd need to know. |
| |
| ## Alternatives |
| |
| ### Continue as usual |
| |
| The only real alternative here is to make no change, which has all of the |
| problems listed in the overview of this topic. |
| |
| #### Pros |
| |
| * Requires no effort short-term |
| * Editions upgrades will **never** be breaking changes |
| |
| #### Cons |
| |
| * Likely to cause problems as soon as edition 2024 |
| * Introducing new features is dangerous and unpredictable |
| * Dropping features affects all editions simultaneously |
| * The features supported in each edition can vary between protobuf releases |
| * High cognitive overhead for our users. They'd need to track the progress of |
| every feature individually across releases. |
| |
| ### Full Validation for Dynamic Messages |
| |
| None of the generators where editions have already been rolled out require any |
| changes. We will need to add validation layers to runtimes that support dynamic |
| messages though, to make sure there are no invalid descriptors floating around. |
| Any runtime that supports dynamic messages should have reflection, and the same |
| reflection-based algorithm will need to be duplicated everywhere. For each |
| `FeatureSet` specified on a descriptor: |
| |
| ``` |
| absl::Status Validate(Edition edition, Message& features) { |
| std::vector<const FieldDescriptor*> fields; |
| features.GetReflection()->ListFields(features, &fields); |
| for (const FieldDescriptor* field : fields) { |
| // Recurse into message extension. |
| if (field->is_extension() && |
| field->cpp_type() == FieldDescriptor::CPPTYPE_MESSAGE) { |
| CollectLifetimeResults( |
| edition, message.GetReflection()->GetMessage(message, field), |
| results); |
| continue; |
| } |
| |
| // Skip fields that don't have feature support specified. |
| if (!field->options().has_feature_support()) continue; |
| |
| // Check lifetime constrains |
| const FieldOptions::FeatureSupport& support = |
| field->options().feature_support(); |
| if (edition < support.edition_introduced()) { |
| return absl::FailedPrecondition(absl::StrCat( |
| "Feature ", field->full_name(), " wasn't introduced until edition ", |
| support.edition_introduced())); |
| } |
| if (support.has_edition_removed() && edition >= support.edition_removed()) { |
| return absl::FailedPrecondition(absl::StrCat( |
| "Feature ", field->full_name(), " has been removed in edition ", |
| support.edition_removed())); |
| } else if (support.has_edition_deprecated() && |
| edition >= support.edition_deprecated()) { |
| ABSL_LOG(WARNING) << absl::StrCat( |
| "Feature ", field->full_name(), " has been deprecated in edition ", |
| support.edition_deprecated(), ": ", support.deprecation_warning()); |
| } |
| } |
| } |
| ``` |
| |
| #### Pros |
| |
| * Prevents any feature lifetime violations for any language, in any language |
| * Easier to understand |
| * Less error-prone |
| * Easy to test with fake features |
| |
| #### Cons |
| |
| * Only works post-build, which requires a huge amount of code in every |
| language to walk the descriptor tree applying these checks |
| * Performance concerns, especially in upb |
| * Duplicates protoc validation, even though most languages perform |
| significantly looser checks on dynamic messages |
| |
| #### Pros |
| |
| * Minimizes the amount of reflection needed |
| |
| #### Cons |
| |
| * Can't validate extensions for languages we don't know about, since they're |
| not built into the binary |
| * Potential version skew between pool and runtime features |
| * Requires reflection stripping unexpected fields |
| * Difficult to understand the algorithm from the code |