feature name | start date | rfc pr |
---|---|---|
Experimental APIs Post 1.x |
2020-09-08 |
When CDK version 2.0
is released to General Availability (GA), the single
monolithic Construct Library package we vend will no longer allow breaking
changes in its main modules. The purpose of this RFC is to discuss the
motivation behind this change, and to describe how API experiments will be
carried out in the CDK post 2.0
.
CDK releases contain a combination of stable and unstable features, which has
proven to be a pain point for customers. The AWS CDK packages are released
frequently -- at least once per week, sometimes more -- and each release
increments the minor version number (e.g. 1.59.0
to 1.60.0
). In the planned
2.0
release of CDK, the main focus of the major version upgrade is to stop
packaging modules separately and to include them all in one package called
aws-cdk-lib
. This will solve a number of problems related to peer dependencies
that make it harder to vend third-party libraries based on the CDK, but it does
not address the backwards compatibility problem caused by minor releases
containing breaking changes to unstable APIs.
The CDK uses an exception to semantic versioning by labeling certain APIs (and entire modules) as unstable, to allow us to make breaking changes to those APIs in minor version upgrades. There is precedent for this in other open source projects, but for CDK users, it has been a constant source of pain and frustration. Users who do not carefully read and understand the documentation simply install packages, copy sample code, make a few tweaks and put the code into production. When they later upgrade to a new version, they are surprised to find that their code no longer works. The perception of instability has driven some users away, or caused them to limit their usage of CDK to the fundamental L1 constructs, which do not provide them with the benefits of higher-level abstractions.
This RFC proposes that we stop releasing breaking changes in the main package we
vend. A user that installs aws-cdk-lib
using NPM or pip
or any other package
manager should be confident there will be no breaking changes in the 2.x
line
of releases for its lifetime.
These are the goals of this RFC, in order from most to least important:
1. Using CDK APIs that don't guarantee backwards-compatibility should require clear, explicit opt-in
It should be absolutely obvious to a CDK customer when they are opting in to
using an API that might have backwards-incompatible changes in the future. From
experience, we have determined that including that information in the ReadMe
file of a module, or in the inline code documentation available in an
editor/IDE, does not meet the criteria of "absolutely obvious".
If a customer is not aware of the stable vs unstable distinction, that means they're using only stable APIs, and that they will not be broken with minor version CDK releases.
In our estimation, the CDK cannot be successful without growing an expansive collection of third-party packages that provide reusable Constructs on various levels of abstraction. Changing to vending the Construct Library as a monolithic package is one part of making that possible; we should make sure our approach to unstable code also takes this into account.
We believe that one of the reasons for CDK's success is the ability to release functionality quickly into the hands of customers, to get their feedback. The ability to release experiments is crucial for that speed; if every single released API decision carried with it the absolute burden of being 100% backwards compatible, that would slow the pace of CDK innovation considerably (especially third-party contributions), and would lengthen the feedback loop from our customers on the quality of the proposed APIs.
For those reasons, we consider it essential for the CDK team to retain the capability to perform experiments with our APIs (of course, only those that are clearly marked as such).
Our development methodology is highly dependent on feedback from the community before finalizing APIs. To encourage users to use and provide feedback on experimental APIs, we should make them easy to use.
To achieve the goals of this RFC, we propose the following changes:
Because of a combination of how aws-cdk-lib
will be depended on by third-party
libraries (through peer dependencies), and the goals of this RFC, it will no
longer be possible to make breaking changes to code inside aws-cdk-lib
's main
modules.
(See Appendix A below for a detailed explanation why that is)
AWS CDK v2 release notes:
Starting with version 2.0.0 of the AWS CDK, all modules and members will become stable. This means that from this release, we are committed to never introduce breaking changes in a non-major bump.
One of the most common feedback we hear from customers is that they love how fast new features are added to the AWS CDK, we love it to. In v1, the mechanism that allowed us to add new features quickly was marking them as "experimental". Experimental features were not subject to semantic versioning, and we allowed breaking changes to be introduced in these APIs. This is the other most common feedback we hear from customers - breaking changes are not ok.
To make sure we can keep adding features fast, while keeping our commitment to not release breaking changes, we are introducing a new model - API Previews. APIs that we want to get in front of developers early, and are not yet finalized, will be added to the AWS CDK with a specific suffix:
PreX
. APIs with the preview suffix will never be removed, instead they will be deprecated and replaced by either the stable version (without the suffix), or by a newer preview version. For example, assume we add the methodgrantAwesomePowerPre1
:/** * This methods grants awesome powers */ grantAwesomePowerPre1();Times goes by, we get feedback that this method will actually be much better if it accept a
Principal
. Since adding a required property is a breaking change, we will addgrantAwesomePowerPre2()
and deprecategrantAwesomePowerPre1
:/** * This methods grants awesome powers to the given principal * * @param grantee The principal to grant powers to */ grantAwesomePowerPre2(grantee: iam.IGrantable) /** * This methods grants awesome powers * @deprecated use grantAwesomePowerPre2 */ grantAwesomePowerPre1()When we decide its time to graduate the API, the latest preview version will be deprecated and the final version -
grantAwesomePower
will be added.Writing the perfect API is hard, some APIs will require many iterations of breaking changes before they can be finalized, others may need a long bake time, and some both. This is especially true when writing a new L2. To make sure we don't make it too hard to contribute new L2s (keep em coming!), we are announcing the
AWS CDK Alpha modules
repo. This repo will be the home of AWS CDK modules which are in a very early stage of development. The alpha repo will contain multiple modules, each with its own prerelease version. When an alpha module is ready for prime time it will be added toaws-cdk-lib
. Check out our contribution guide for more details. If you are writing a new L2, and you are not sure where it should be added, open a GitHub issue in the AWS CDK main repo.
This section discuss adding a new modules to aws-cdk-lib
. Alpha and beta are
optional, modules can be added directly to aws-cdk-lib
as stable.
This stage is intended for modules that are in early development stage, they
change rapidly and may incurs many breaking changes. These modules will be
released separately from aws-cdk-lib
and will have their own version, which
allows for breaking changes in accordance to semver, e.g 0.x
or prerelease
qualifiers.
In order to release a module as alpha, the following conditions must be met:
- Module from
aws-cdk-lib
can not depend on it. - Can not depend on any other alpha module **.
The purpose of the first condition is to prevent cyclic dependencies. To
illustrate the purpose of the second condition lets look at an example. Assume
we have an alpha module A, which depends on an alpha module B, graduating module
A means it will be added to aws-cdk-lib
, since module A depends on module B,
it means that adding module A to aws-cdk-lib
breaks condition 1 and creates a
cyclic dependency. To prevent the cycle we will have to graduate B as well,
which is not necessarily what we want. Additionally, if module B is an alpha
module, module A will have to declare a fixed dependency on it, locking its
consumers to the same version of module B - defeating the purpose of
peerDependencies.
Condition 2 is marked with ** to note that there might be cases in which it will make sense for unstable modules to depend on other unstable modules. This is also evident from he fact that are several such dependencies in v1, see appendixes for a full list.
Such cases will be considered on a per case basis.
In this stage modules are added to aws-cdk-lib
with a preview suffix in the
name of the module, e.g aws-batch-beta-x
. Modules in this stage are not
allowed to introduce any breaking changes to their API. Any non backward
compatible change will be introduced via deprecation**.
** The deprecation process will be discussed in the API previews specification
In this stage modules are added to aws-cdk-lib
under their final name, e.g
aws-batch
. Addition of new APIs should follow the API previews specification.
The question of whether we should release experimental modules under one package or as separate modules has been discussed in this RFC. See Option 3, and option 2.
I suggest we release alpha modules as separate modules (Option 3). The disadvantages of this approach, listed in this RFC are:
- It's not possible for stable modules to depend on unstable ones (see Appendix B for data on how necessary that is for the CDK currently), with the same implications as above.
- It's not possible for unstable modules to depend on other unstable modules (see Appendix B for data on how necessary that is for the CDK currently), as doing that brings us back to the dependency hell that mono-CDK was designed to solve.
- Graduating a module to stable will be a breaking change for customers. We can mitigate this downside by keeping the old unstable package around, but that leads to duplicated classes.
1 and 3 applies to both option 3 and option 2.
Releasing each alpha module separately has the advantage of allowing consumers
to choose when they want to upgrade a specific module. If we release all modules
under a single package, in which every release may include breaking changes to
any numbers of its modules, users are forced to accept the breaking changes in
all modules, even if they only want to upgrade a single module. For example,
assume the uber package includes aws-appmesh
and aws-synthetics
, and that
release 0.x
of the uber package includes breaking changes to both modules, a
customer who only wants the new feature added to aws-synthetics
now must
accept the breaking changes to aws-appmesh
, which might include changes to
both the code and their deployed infrastructure they are not ready to make, e.g
resource replacement. Having separate module means reduces the blast radius of
every update.
As for 2, if an unstable module need to depend on another unstable module, it
will do so using
peerDependencies.
The "dependency hell" of v1 was a result of using dependencies where we should
have been using peerDependencies. dependencies
will lead to multiple copies of
a library, peerDependencies
will not. This subject has been discussed in
length during the RFC review, you can read more in this
comment.
Before v2 release we will need to decide the lifecycle stage of every v1 experimental module. We will review all modules and devise a migration strategy in a separate doc
Advantages:
- Preview APIs can be used without declaring a fixed version on
aws-cdk-lib
. - Since old versions of an API will only be deprecated and not removed, if
needed, we will be able to push critical updates to these versions as well.
For example, if we discover that
grantWrite
grants overly permissive permissions, and the same occur in all of its experimental (deprecated) predecessors, we will be able to push the fix to them as well. This will allow us to get the fix to more customers in case of a critical fix. - Libraries will be able to use preview APIs without locking their consumers to
a specific version of
aws-cdk-lib
(or any other module we vend). - Same solution across all languages.
Disadvantages:
- Graduating a module will require a lot of code changes from our users to remove the prefix/suffix.
- User does not have a clear motivation to upgrade to a newer version.
- Low adoption. Users might be hesitant to use an API with a
PreX
in its name, assuming it means "will break". - Cluttering the code base. Although most IDEs will mark deprecated properties
with a
strikethrough, they will still be listed by autocomplete. - Will force some pretty long and ugly names on our APIs. Many previews APIs will result in a less aesthetic user code.
- A lot of deprecated code in aws-cdk-lib, possibly blowing up
aws-cdk-lib
. This might not be a real concern as we can reuse a lot the code between different version of an API.
How can we encourage users to upgrade to a newer version?
When a new minor version introduces a breaking change to an API, users have a
clear motivation to upgrade to the new version of the API - they must do so in
order to upgrade to a newer version of the AWS CDK. If the API is only
deprecated, users have no motivation to upgrade to a newer version of the API,
even worse, they might not be aware that a newer version exists. One way to
encourage users to upgrade to a newer version of an API, is to supply tools that
will inform users. For example, we can add a capability to the cdk doctor
command that will notify users that their CDK application is using deprecated
experimental APIs.
Executing cdk doctor
will print:
neta@dev/my-cdk-application$ cdk doctor
Newer versions of APIs previews your application is using are available. You should consider upgrading.
To see which previews APIs can be upgraded, execute `cdk --show-deprecated-api-usage`
If there are no breaking changes, are these APIs really experimental?
Given that preview APIs are safe to use, and no breaking changes will be
introduced to them, we might consider not referring to them in any special way,
and if needed, simply add a version to the API name. The first version of
grantWrite
will be named grantWrite
, the second version will be named
grantWriteV1
and so on. While a preview API will not break, we should still
use a naming scheme that convey its non-final nature for the following reasons:
- Real-estate:
grantWrite
is a much better name thangrantWritePre3
for the final API. - Encourage feedback: Declaring an API as "in preview" encourage the community to supply feedback.
- Setting the right expectations: When an API in preview users are aware that this is not the final version of the API, and its deprecation is expected.
This chapter discusses additional precautions we can choose to implement to re-inforce goal #1 above. These are orthogonal to the decision on how to divide the stable and unstable modules (meaning, we could implement any of these with each of the options above).
These could be added to either @experimental
APIs in stable modules, to all
APIs in unstable modules, or both.
In this variant, we would add a runtime check into all unstable APIs that immediately fails with an exception if the following context is missing:
{
"context": {
"@aws-cdk:allowExperimentalFeatures": true
}
}
Note that cdk init
will create a project with this context value set to
false
.
To avoid the manual and error-prone process of adding this check to every single
unstable API, we will need to modify JSII so that it recognizes the
@experimental
decorator, and adds this check during compilation.
Advantages:
- Changing the context flag will be an explicit opt in from the customer to agree to use unstable APIs.
Disadvantages:
- This will force setting the flag also for transitive experimental code (for example, when an unstable API is used as an implementation detail of a construct, but not in its public interface), which might be confusing.
- Since there is a single flag for all unstable code, setting it once might hide other instances of using unstable code, working against stated goal #1.
- Requires changes in JSII.
We can modify awslint
to force a certain naming convention for unstable code,
for example to add a specific prefix or suffix to all unstable APIs.
Advantages:
- Should fulfill goal #1 - it will be impossible to use an unstable API by accident.
- Does not require changes in JSII, only in
awslint
.
Disadvantages:
- Will force some pretty long and ugly names on our APIs.
- Graduating a module will require a lot of code changes from our customers to remove the prefix/suffix.
This section explains why it will not be possible to break backwards compatibility of any API inside the stable modules of mono-CDK.
Imagine we could break backwards compatibility in the code of the aws-cdk-lib
main modules. The following scenario would then be possible:
Let's say we have a third-party library, my-library
, that vends MyConstruct
.
It's considered stable by its author. However, inside the implementation of
MyConstruct
, it uses an experimental construct, SomeExperiment
, from
mono-CDK's S3 module. It's just an implementation detail, though; it's not
reflected in the API of MyConstruct
.
my-library
is released in version 2.0.0
, and it has a peer dependency on
aws-cdk-lib
version 2.10.0
(with a caret, so "aws-cdk-lib": "^2.10.0"
).
Some time passes, enough that aws-cdk-lib
is now in version 2.20.0
. A CDK
customer wants to use my-library
together with the newest and shiniest
aws-cdk-lib
,2.20.0
, as they need some recently released features. However,
incidentally, in version 2.15.0
of aws-cdk-lib
, SomeExperiment
was broken
-- which is fine, it's an experimental API. Suddenly, the combination of
my-library
2.0.0
and aws-cdk-lib
2.20.0
will fail for the customer at
runtime, and there's basically no way for them to unblock themselves other than
pinning to version 2.14.0
of aws-cdk-lib
, which was exactly the problem
mono-CDK was designed to prevent in the first place.
This section contains the snapshot of the interesting dependencies between Construct Library modules as of writing this document.
⚠️ Stable module '@aws-cdk/aws-applicationautoscaling' depends on unstable module '@aws-cdk/aws-autoscaling-common'
⚠️ Stable module '@aws-cdk/aws-autoscaling' depends on unstable module '@aws-cdk/aws-autoscaling-common'
⚠️ Stable module '@aws-cdk/aws-events-targets' depends on unstable module '@aws-cdk/aws-batch'
⚠️ Stable module '@aws-cdk/aws-lambda' depends on unstable module '@aws-cdk/aws-efs'
⚠️ Stable module '@aws-cdk/aws-stepfunctions-tasks' depends on unstable module '@aws-cdk/aws-batch'
⚠️ Stable module '@aws-cdk/aws-stepfunctions-tasks' depends on unstable module '@aws-cdk/aws-glue'
ℹ️️ Unstable package '@aws-cdk/aws-apigatewayv2-integrations' depends on unstable package '@aws-cdk/aws-apigatewayv2'
ℹ️️ Unstable package '@aws-cdk/aws-appmesh' depends on unstable package '@aws-cdk/aws-acmpca'
ℹ️️ Unstable package '@aws-cdk/aws-backup' depends on unstable package '@aws-cdk/aws-efs'
ℹ️️ Unstable package '@aws-cdk/aws-docdb' depends on unstable package '@aws-cdk/aws-efs'
ℹ️️ Unstable package '@aws-cdk/aws-ses-actions' depends on unstable package '@aws-cdk/aws-ses'
These potential solutions to problem #2 were discarded by the team as not viable.
In this option, we would use the namespacing features of each language to vend a separate namespace for the experimental APIs. The customer would have to explicitly opt-in by using a language-level import of a namespace with "experimental" in the name.
Example using stable and unstable Cognito APIs:
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as cognito_preview from 'aws-cdk-lib/experimental/aws-cognito';
const idp = new cognito_preview.UserPoolIdentityProviderOidc(this, 'OIDC', { ... });
const supported = [cognito.UserPoolClientIdentityProvider.custom("MyProviderName")];
const userPoolClient = new cognito.UserPoolClient(...);
Advantages:
- It's possible for stable module to depend on unstable ones (see Appendix B for data on how necessary that is for the CDK currently)
- It's possible for unstable modules to depend on other unstable modules (see Appendix B for data on how necessary that is for the CDK currently).
Disadvantages:
- Might be considered less explicit, as a customer never says they want to
depend on a package containing unstable APIs, or with
0.x
for the version. - If a third-party package depends on an unstable API in a non-obvious way (for
example, only in the implementation of a construct, not in its public API),
that might break for customers when upgrading to a version of
aws-cdk-lib
that has broken that functionality compared to theaws-cdk-lib
version the third-party construct is built against (basically, the same scenario from above that explains why we can no longer have unstable code in stable mono-CDK modules). None of the options solve the problem of allowing third-party libraries to safely depend on unstable Construct Library code; however, the fact that all unstable code in this variant is shipped inaws-cdk-lib
makes this particular problem more likely to manifest itself. - Graduating a module to stable will be a breaking change for customers. We can mitigate this downside by keeping the old unstable module around, but that leads to duplicated classes in the same package.
Verdict: discarded because disadvantage #2 was considered a show-stopper.
In this option, we will fork the CDK codebase and maintain 2 long-lived
branches: one for version 2.x
, which will be all stable, and one for version
3.x
, which will be all unstable.
Example using stable and unstable Cognito APIs: (assuming the dependency on
"aws-cdk-lib"
is in version "3.x.y"
):
import * as cognito from 'aws-cdk-lib/aws-cognito';
const idp = new cognito.UserPoolIdentityProviderOidc(this, 'OIDC', { ... });
const supported = [cognito.UserPoolClientIdentityProvider.custom("MyProviderName")];
const userPoolClient = new cognito.UserPoolClient(...);
Advantages:
- It's possible for unstable modules to depend on other unstable modules (see Appendix B for data on how necessary that is for the CDK currently).
Disadvantages:
- It's not possible for stable modules to depend on unstable ones (with the same implications as above).
- Does not make it obvious to customers that this is unstable (
3.x
is considered stable in semantic versioning). - We are going from "some code is stable, some is unstable" to "all of this is unstable", which seems to be against the customer feedback we're hearing that's the motivation for this RFC.
- Two long-lived Git branches will mean constant merge-hell between the two,
and since
3.x
has free rein to change anything, there will be a guarantee of constant conflicts between the two. - Fragments the mono-CDK third-party library community into two.
- Very confusing when we want to release the next major version of the CDK (I
guess we go straight to
4.x
...?). - The fact that all code in
3.x
is unstable means peer dependencies don't work (see above for why). - Graduating a module to stable will be a breaking change for customers. We can mitigate this downside by keeping the old unstable package around, but that leads to duplicated classes between the 2 versions.
Verdict: discarded as a worse version of option #5.
Instead of vending the unstable modules together with the stable ones, we can
vend a second mono-CDK, aws-cdk-lib-experiments
(actual name can be changed
before release of course). A customer will have to explicitly depend on
aws-cdk-lib-experiments
, which will be released in version 0.x
to make it
even more obvious that this is unstable code. aws-cdk-lib-experiments
would
have a caret peer dependency on aws-cdk-lib
.
Example using stable and unstable Cognito APIs:
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as cognito_preview from 'aws-cdk-lib-experiments/aws-cognito';
const idp = new cognito_preview.UserPoolIdentityProviderOidc(this, 'OIDC', { ... });
const supported = [cognito.UserPoolClientIdentityProvider.custom("MyProviderName")];
const userPoolClient = new cognito.UserPoolClient(...);
Advantages:
- Very explicit (customer has to add a dependency on a package with
"experiments" in the name and version
0.x
). - It's possible for unstable modules to depend on other unstable modules (see Appendix B for data on how necessary that is for the CDK currently).
Disadvantages:
-
It's not possible for stable modules to depend on unstable ones (see Appendix B for data on how necessary that is for the CDK currently). This has serious side implications:
- All unstable modules that have stable dependents today will have to be
graduated before
v2.0
is released. - Before a module is graduated, all of its dependencies need to be graduated.
- It will not be possible to add new dependencies on unstable modules to stable modules in the future (for example, that's a common need for StepFunction Tasks).
- All unstable modules that have stable dependents today will have to be
graduated before
-
Graduating a module to stable will be a breaking change for customers. We can mitigate this downside by keeping the old unstable module around, but that leads to duplicated classes.
In this option, each experimental library will be vended as a separate package.
Each would have the name "experiments" in it (possible naming convention:
@aws-cdk-lib-experiments/aws-<service>
), and would be released in version
0.x
to make it absolutely obvious this is unstable code. Each package would
declare a caret peer dependency on aws-cdk-lib
.
Example using stable and unstable Cognito APIs:
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as cognito_preview from '@aws-cdk-lib-experiments/aws-cognito';
const idp = new cognito_preview.UserPoolIdentityProviderOidc(this, 'OIDC', { ... });
const supported = [cognito.UserPoolClientIdentityProvider.custom("MyProviderName")];
const userPoolClient = new cognito.UserPoolClient(...);
Advantages:
- Very explicit (customer has to add a dependency on a package with
"experiments" in the name and version
0.x
). - This is closest to the third-party CDK package experience our customers will have.
Disadvantages:
- It's not possible for stable modules to depend on unstable ones (see Appendix B for data on how necessary that is for the CDK currently), with the same implications as above.
- It's not possible for unstable modules to depend on other unstable modules (see Appendix B for data on how necessary that is for the CDK currently), as doing that brings us back to the dependency hell that mono-CDK was designed to solve.
- Graduating a module to stable will be a breaking change for customers. We can mitigate this downside by keeping the old unstable package around, but that leads to duplicated classes.
Instead of splitting the stable and experimental APIs into two different packages, we release two builds of the same source code, where the experimental build is a superset of the stable build.
This can be done automatically, by using jsii to strip out experimental APIs
from the public interface of the stable release. The implementation (.js
files) will be the same in both releases, so stable APIs in aws-cdk-lib
can
safely use experimental APIs internally (though not in their public API
surfaces).
TypeScript | JavaScript | Java, Python, C#, Go |
---|---|---|
Stripped from .d.ts |
N/A | No bindings generated by jsii |
In TypeScript, the compiler will prevent usage of APIs that are not advertised
in the .d.ts
files. Users will be able to bypass this guarantee by using
as any
though.
For JavaScript, an IDE will (hopefully¹) not autocomplete APIs missing from the
.d.ts
files; otherwise nothing prevents users from calling them. On the other
hand, JavaScript devs are by definition required to read the docs, so they can't
miss the experimental banners displayed there.
For jsii client languages, the bindings for experimental APIs will simply not exist so there's no way to work around that (though we'll have to check whether for example Python makes it possible to pass struct values that aren't in the declared API surface).
¹) A smart enough IDE could discover those APIs by parsing the JavaScript directly, or by doing dynamic execution and runtime inspection of objects. This was already being done before TypeScript existed, I did not survey the current landscape of IDEs to figure out which ones use what techniques to provide autocomplete for users.
We also commonly use @experimental
to indicate experimental struct properties
for Props
types.
There will be code that users can write that will pass a props object that technically contains non-advertised, experimental properties. Without additional runtime support the object constructor will happily interpret the presence of those properties and make decisions based off of them.
An example in TypeScript:
// Construct
interface StableConstructProps {
readonly stableProp: string;
/** @experimental */
readonly experimentalProp: string;
}
class StableConstruct {
constructor(scope: Construct, id: string, props: StableConstructProps) {
super(scope, id);
if (props.experimentalProp) {
console.log('Experimental features activated!');
}
}
}
//----------------------------------------------------------------------
// THE FOLLOWING CLIENT CODE IS USING THE STABLE BUILD
//
// As far as this client code is aware, StableConstructProps looks like this:
//
// interface StableConstructProps {
// readonly stableProp: string;
// }
const props = {
stableProp: 'foo',
experimentalProp: 'bar',
};
// Even though we're using "stable mode", the following code still
// prints 'Experimental features activated!'.
//
// The call below is allowed because 'typeof props' is a superset of StableConstructProps.
new StableConstruct(this, 'Id', props);
// In contrast the call below would NOT be allowed:
new StableConstruct(this, 'Id', {
stableProp: 'foo',
experimentalProp: 'bar',
});
We would need runtime support
(if (props.experimentalProp && IN_EXPERIMENTAL_MODE) { ... }
) to detect and
prevent this, or accept it.
The example above is for TypeScript; it obviously also holds for JavaScript, and might hold for jsii languages in certain cases.
There are a number of different ways we can choose to distinguish the two builds. See Appendix D for an evaluation of all the possibilities.
- Distinguish by package name
- Can't be used in pip: there's no way to have an app that uses the experimental library work with a library that uses the stable library (no way to prevent the transitive dependency package with a different name from being installed).
- Distinguish by prerelease tag
- NuGet: requires that experimental version semver-sorts after the matching stable version, otherwise it errors out and fails the restore operation.
- npm: will always complain when trying to satisfy a stable requirement with an experimental version, regardless of how it sorts.
- Maven: does not recognize prerelease tags. This means that apps can't
have an open-ended version range since they might accidentally pick up
experimental versions (a Maven range of
[1.60.0, 2.0.0)
will match1.61.0-experimental
). This might not be a problem as this is rarely done in practice (since Maven doesn't have a lock file, thepom
itself serves as the lock file).
- Distinguish by major version
- Very similar to prerelease tags, except we don't depend on Package Manager's support for prerelease tags; all PMs support major versions.
Even though there are downsides, the prerelease tag is the least bad of the alternatives and the one that's (probably) easiest to explain.
We have to work around the version ordering problem though, by making sure the experimental version sorts after the stable version. We can either use the minor or the patch version:
# Experimental uses minor bump
1.60.0 < 1.61.0-experimental
# Experimental uses (fake) patch bump
1.60.0 < 1.60.100-experimental
Advantages:
- It's possible for stable modules to depend on unstable ones (see Appendix B for data on how necessary that is for the CDK currently).
- Stabilizing code will not be a breaking change for customers.
- Stabilizing code will be a simple operation for CDK developers (remove an annotation).
- No additional management overhead of multiple packages.
- It's possible for unstable modules to depend on other unstable modules.
Disadvantages:
- Stable/experimental versioning scheme is not based on any well-known industry standard, we're going to have to clearly explain it to people.
- Requires changes to jsii
- Requires changes to the docs to make switching between editions possible
- Protection not offered for JavaScript users, can be bypassed in TypeScript;
although something is to be said for it being the same with
private
fields today. - Does not satisfy
goal 4 - Using experimental modules should be easy.
Customers that wants to use a single experimental API must pay the cost of
using a different version of the entire
aws-cdk-lib
. This is a non trivial cost due to the following:- Migrating to either a new major version, or a prerelease version, is a
process that usually includes accepting lot of breaking changes. While this
may not be the case for
aws-cdk-lib
, users wil be still hesitant as it is the standard meaning of such migration. - Users which uses the experimental version might use experimental APIs they didn't intend to, similar to the experimental experience in v1.
- Migrating to either a new major version, or a prerelease version, is a
process that usually includes accepting lot of breaking changes. While this
may not be the case for
Option 5 should be move to the appendices section, it is added here for the purpose of minimizing the diff in the review process. It will be moved once the RFC is finalized.
Option 5 was rejected since there is no way to model the relationship between the "experimental" version and the "stable" version through semantic versioning.
According to the semver specification, a
precedence between versions is calculated only if the major, minor
and patch are equal. This means that there is no way for a constructs
library to declare a range of supported versions which include an experimental
version. Sticking to the above example, a CDK construct library declaring a peer
dependency on version ^1.60.0
of aws-cdk-lib
, can not be used in a CDK
application that declares a dependency on aws-cdk-lib
version
1.61.0-experimental
. This is because the patch part in 1.60.0
and
1.61.0-experimental
is not equal, which means 1.61.0-experimental
does not
satisfy ^1.60.0
. In npm versions prior to npm-v7, if peerDependencies
requirements are not met, executing npm install
would only issue a warning. In
npm-v7, which automatically tries to install peerDependencies
, executing
npm install
will throw an error.
To illustrate the user experience with npm-v7. The below is the output of
executing npm install
in a CDK application (my-cdk-application
), which
declares a dependency on a aws-cdk-lib
version 1.61.0-experimental
, and on a
CDK library (my-construct-lib
) which itself declares a peer dependency on
aws-cdk-lib
version ^1.60.0
:
npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR!
npm ERR! While resolving: [email protected]
npm ERR! Found: [email protected]
npm ERR! node_modules/aws-cdk-lib
npm ERR! aws-cdk-lib"@1.61.0-experimental" from the root project
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! peer aws-cdk-li@"^1.60.0" from [email protected]
npm ERR! node_modules/my-construct-lib
npm ERR! my-construct-lib@"1.6.0" from the root project
npm ERR!
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force, or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
npm ERR!
Users can work around this by executing npm install --force
which means that
npm will not check for version compatibility at all, and therefore not an
acceptable solution.
This was rejected due to similar reasons as listed in the disadvantages of Option 4: separate V3 that's all unstable.
We have the following options to pick from:
- Different package names (
aws-cdk-lib
vsaws-cdk-lib-experimental
). - Pre-release version tag
- Different major versions
We need to evaluate all of these, for each package manager, on the following criteria:
- Can applications depend on an upwards unbounded range of stable builds? (important)
- Can an application using the experimental build use a library using the
stable build? (important)
- Without the package manager complaining (preferably)
- Can a library declare a tool-checked dependency on a specific experimental version? (preferably)
We have ruled out the requirement that an application using stable may transparently use a library using experimental. We will disallow this as it's not feasible. We're trying to achieve the following compatibility matrix:
. | Stable App | Experimental App |
---|---|---|
Stable Lib | floating versions | lib floating, app pinned |
Experimental Lib | - | pinned versions |
NOTE: in the following sections, I'll be using the terms "stable app", "experimental app", "stable lib" and "experimental lib" as stand-ins for the more accurate but much longer "app that depends on stable CDK", "app that depends on experimental CDK", etc.
We would vend two different package names, for example:
aws-cdk-lib
aws-cdk-lib-experimental
The tl;dr of this section is as follows. Read below for details.
. | Stable apps | Experimental app uses Stable lib | ...without complaints | Lib advertises Experimental |
---|---|---|---|---|
NPM | yes | with package aliases | yes | no |
Maven | yes | with excluded dependencies | yes | yes |
pip | yes | no | - | no |
NuGet | yes | no | no | yes |
Go | ? | ? | ? |
In NPM-based languages (JavaScript and TypeScript), the package name appears in
source code (in the require()
statement). If we changed the library name, we
wouldn't be able to use a library that uses stable code (it would contain
require('aws-cdk-lib')
) in an application that uses experimental code:
//------------- LIBRARY THAT USES STABLE ---------------
// package.json
{
"peerDependencies": {
"aws-cdk-lib": "^1.60.0"
}
}
// index.ts
import * as cdk from 'aws-cdk-lib'; // <- locally perfectly sane
//------------- APPLICATION THAT USES EXPERIMENTAL AND THIS LIB ---------------
// package.json
{
"devDependencies": {
"lib-that-uses-stable": "^1.2.3",
"aws-cdk-lib-experimental": "1.60.0"
}
}
// app.ts
import * as cdk from 'aws-cdk-lib-experimental'; // <- locally perfectly sane
Well oops. Turns out we can't have a library that depends on aws-cdk-lib
in an
application that uses aws-cdk-lib-experimental
; NPM will complain about the
missing peerDependency
, and the require('aws-cdk-lib')
call will just fail
at runtime.
NPM 6.9.0 (released May 2019) introduces a feature that helps with this:
package aliases
which allows installing a package under a different name, so we could install
aws-cdk-lib-experimental
under the name aws-cdk-lib
. The application
package.json
will now look like this:
//------------- APPLICATION THAT USES EXPERIMENTAL AND STABLE LIB ---------------
// package.json
{
"devDependencies": {
"lib-that-uses-stable": "^1.2.3",
"aws-cdk-lib": "npm:[email protected]",
}
}
// app.ts
import * as cdk from 'aws-cdk-lib';
So that's cool.
What would a construct library that depends on an experimental build look like, and could we use that in the same application?
An experimental library would declare this:
{
"peerDependencies": {
"aws-cdk-lib": "1.60.0"
// Note: ideally you'd like to express "aws-cdk-lib-experiments": "1.60.0" here, but
// if you do that "npm ls" will complain about an unmet peer dependency
},
"devDependencies": {
"aws-cdk-lib": "npm:[email protected]"
}
}
This will all work as intended and put code in the right places.
However, we lose the tool-checked dependency on experimental: from NPM's point of view the library depends on stable CDK (whereas we want it to declare that it depends on experimental CDK). It's only notes in the README that can inform the consumer otherwise.
No need to change the sources when using a stable library with an experimental app, as the namespaces and class names will be the same.
Using a stable library in an experimental app can be done in two ways:
- The library uses the
provided
dependency flavor (similar topeerDependencies
in NPM). Requires the app to bring the right version of CDK. - The app that wants to use the experimental build uses dependency exclusions to remove the dependency on the stable build (looks like this is necessary for every stable library individually)
No need to change the sources when using a stable library with an experimental app, as the namespaces and class names will be the same.
pip
transitively fetches from the library's setup.py
, and there's no way to
override that.
Our only option around that is to have stable libraries not declare any CDK
dependency in setup.py
, but then we also lose the ability for libraries to
specify their dependency version, which is not really acceptable.
Effectively, this seems to be a no-go.
No need to change the sources when using a stable library with an experimental app, as the namespaces and class names will be the same.
...pending responses from .NET SDK team...
???
We would vend the experimental build under a prerelease tag, for example:
1.60.0
1.60.0-experimental
Because of semver ordering, if we use the numbering above then the
experimental
version will sort before the stable version, which may lead to
problems because a library's dependency requirement of ^1.60.0
would not be
satisfied by a version numbered 1.60.0-experimental
.
We could get rid of some of the ordering and warning problems by making sure the experimental version semver-orders after the stable version by making sure we bump some number:
. | Stable apps | Experimental app uses Stable lib | ...without complaints | Lib advertises Experimental |
---|---|---|---|---|
NPM | yes | yes | no | yes |
Maven | no but that's okay | yes | yes | yes |
pip | yes | yes | if experimental sorts later | yes |
NuGet | yes | if experimental sorts later | yes | yes |
Go | ? | ? | ? | ? |
Can apps use stable ranges: yes, ^1.60.0
will not auto-pick
1.61.1-experimental
for downloading.
Experimental app uses stable lib: can be done, the lib only uses a
peerDependency
.
However, if the peerDependency
of a library is stable (^1.60.0
) and we're
trying to satisfy it with an experimental version, npm ls
will complain
regardless of where the experimental version sorts with respect to the stable
version.
It will work, but npm will complain.
Stable ranges: no, [1.60.0,)
will include 1.61.1-experimental
. However,
this might not be an issue as pom.xml
typically serves as the lock file and
people will not build applications with open-ended ranges in the pom file.
If we wanted to, we could vend as 1.60.0-experimental-SNAPSHOT
; snapshot
versions will not match a range unless specifically requested. However,
SNAPSHOT
versions are not intended for this, they are intended to indicate
mutability and bypass caches: every configurable time period (by default, every
day but can be every build, and snapshots can also be disabled altogether) the
SNAPSHOT
version will be fetchd again, as it is assumed to be mutable.
Maven uses a "closest to root wins" dependency model, so the application can substitute an experimental version in place of the declared stable compatibility.
It will not complain about incompatible versions unless you really really ask for diagnostics (and even then it's hard to get it to show an error).
Stable ranges: yes, >=1.60.0
will not match version 1.60.1-experimental
ref
Experimental app uses stable lib: pip
allows overriding transitive
dependencies using requirements.txt
.
It will complain if the experimental version sorts before the stable version, but will NOT complain if the experimental version sorts after the stable version. Everything still gets installed, even if it complains.
Example of a complaint (using a different package):
awscli 1.18.158 has requirement s3transfer<0.4.0,>=0.3.0, but you'll have s3transfer 0.2.0 which is incompatible.
(Note: when trying the PyPI testing server, the version number schema I had in
mind: X.Y.Z-experimental
is not accepted, nor is X.Y-experimental
or
X.Yexp
. Ultimately I had to go for 1.60rc1
.)
PJ Pittle from the .NET team has confirmed for us that NuGet will complain and error out if we use:
- Library depends on CDK
>= 1.60.0
- App uses CDK
1.60.0-experimental
(Because of semver ordering.)
The only solution seems to be to make sure that the experimental version sorts after the stable version.
We would vend the experimental build under a prerelease tag, for example:
[email protected]
[email protected]
(odd ranges are experimental)
Or:
[email protected]
[email protected]
(100+ versions are experimental)
. | Stable apps | Experimental app uses Stable lib | ...without complaints | Lib advertises Experimental |
---|---|---|---|---|
NPM | yes | yes | yes | yes |
Maven | yes | yes | yes | yes |
pip | yes | no | - | yes |
NuGet | yes | yes | ? | yes |
Go | ? | ? | ? | ? |
Mostly like pre-release tags, except libraries using stable can explicitly
declare they're usable with both stable and experimental ranges by using
multiple peerDependency
ranges:
{
"peerDependencies": {
"aws-cdk-lib": "^2.60.0 ^102.60.0"
}
}
This would suppress the warning you would otherwise get for a non-compatible version.
Like pre-release tags, except we don't get the mixing in of pre-release versions with regular versions.
Like pre-release tags.
Like pre-release tags.
?