Protocol Buffers - Google's data interchange format (grpc依赖)
https://developers.google.com/protocol-buffers/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
382 lines
18 KiB
382 lines
18 KiB
# 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
|
|
|