Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(dynamodb): allow global replicas with Provisioned billing mode #12159

Merged
merged 2 commits into from
Dec 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/@aws-cdk/aws-dynamodb/README.md
Expand Up @@ -92,6 +92,23 @@ const globalTable = new dynamodb.Table(this, 'Table', {
When doing so, a CloudFormation Custom Resource will be added to the stack in order to create the replica tables in the
selected regions.

The default billing mode for Global Tables is `PAY_PER_REQUEST`.
If you want to use `PROVISIONED`,
you have to make sure write auto-scaling is enabled for that Table:

```ts
const globalTable = new dynamodb.Table(this, 'Table', {
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
replicationRegions: ['us-east-1', 'us-east-2', 'us-west-2'],
billingMode: BillingMode.PROVISIONED,
});

globalTable.autoScaleWriteCapacity({
minCapacity: 1,
maxCapacity: 10,
}).scaleOnUtilization({ targetUtilizationPercent: 75 });
```

## Encryption

All user data stored in Amazon DynamoDB is fully encrypted at rest. When creating a new table, you can choose to encrypt using the following customer master keys (CMK) to encrypt your table:
Expand Down
Expand Up @@ -5,10 +5,13 @@ import { UtilizationScalingProps } from './scalable-attribute-api';
* A scalable table attribute
*/
export class ScalableTableAttribute extends appscaling.BaseScalableAttribute {
private scalingPolicyCreated = false;

/**
* Scale out or in based on time
*/
public scaleOnSchedule(id: string, action: appscaling.ScalingSchedule) {
this.scalingPolicyCreated = true;
super.doScaleOnSchedule(id, action);
}

Expand All @@ -20,6 +23,7 @@ export class ScalableTableAttribute extends appscaling.BaseScalableAttribute {
// eslint-disable-next-line max-len
throw new RangeError(`targetUtilizationPercent for DynamoDB scaling must be between 10 and 90 percent, got: ${props.targetUtilizationPercent}`);
}
this.scalingPolicyCreated = true;
const predefinedMetric = this.props.dimension.indexOf('ReadCapacity') === -1
? appscaling.PredefinedMetric.DYANMODB_WRITE_CAPACITY_UTILIZATION
: appscaling.PredefinedMetric.DYNAMODB_READ_CAPACITY_UTILIZATION;
Expand All @@ -33,6 +37,11 @@ export class ScalableTableAttribute extends appscaling.BaseScalableAttribute {
predefinedMetric,
});
}

/** @internal */
public get _scalingPolicyCreated(): boolean {
return this.scalingPolicyCreated;
}
}

/**
Expand Down
36 changes: 26 additions & 10 deletions packages/@aws-cdk/aws-dynamodb/lib/table.ts
Expand Up @@ -1064,30 +1064,30 @@ export class Table extends TableBase {
private readonly indexScaling = new Map<string, ScalableAttributePair>();
private readonly scalingRole: iam.IRole;

private readonly globalReplicaCustomResources = new Array<CustomResource>();

constructor(scope: Construct, id: string, props: TableProps) {
super(scope, id, {
physicalName: props.tableName,
});

const { sseSpecification, encryptionKey } = this.parseEncryption(props);

this.billingMode = props.billingMode || BillingMode.PROVISIONED;
this.validateProvisioning(props);

let streamSpecification: CfnTable.StreamSpecificationProperty | undefined;
if (props.replicationRegions) {
if (props.stream && props.stream !== StreamViewType.NEW_AND_OLD_IMAGES) {
throw new Error('`stream` must be set to `NEW_AND_OLD_IMAGES` when specifying `replicationRegions`');
}
streamSpecification = { streamViewType: StreamViewType.NEW_AND_OLD_IMAGES };

if (props.billingMode && props.billingMode !== BillingMode.PAY_PER_REQUEST) {
throw new Error('The `PAY_PER_REQUEST` billing mode must be used when specifying `replicationRegions`');
this.billingMode = props.billingMode ?? BillingMode.PAY_PER_REQUEST;
} else {
this.billingMode = props.billingMode ?? BillingMode.PROVISIONED;
if (props.stream) {
streamSpecification = { streamViewType: props.stream };
}
this.billingMode = BillingMode.PAY_PER_REQUEST;
} else if (props.stream) {
streamSpecification = { streamViewType: props.stream };
}
this.validateProvisioning(props);

this.table = new CfnTable(this, 'Resource', {
tableName: this.physicalName,
Expand Down Expand Up @@ -1222,13 +1222,17 @@ export class Table extends TableBase {
throw new Error('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode');
}

return this.tableScaling.scalableWriteAttribute = new ScalableTableAttribute(this, 'WriteScaling', {
this.tableScaling.scalableWriteAttribute = new ScalableTableAttribute(this, 'WriteScaling', {
serviceNamespace: appscaling.ServiceNamespace.DYNAMODB,
resourceId: `table/${this.tableName}`,
dimension: 'dynamodb:table:WriteCapacityUnits',
role: this.scalingRole,
...props,
});
for (const globalReplicaCustomResource of this.globalReplicaCustomResources) {
globalReplicaCustomResource.node.addDependency(this.tableScaling.scalableWriteAttribute);
}
return this.tableScaling.scalableWriteAttribute;
}

/**
Expand Down Expand Up @@ -1298,6 +1302,17 @@ export class Table extends TableBase {
errors.push('a sort key of the table must be specified to add local secondary indexes');
}

if (this.globalReplicaCustomResources.length > 0 && this.billingMode === BillingMode.PROVISIONED) {
const writeAutoScaleAttribute = this.tableScaling.scalableWriteAttribute;
if (!writeAutoScaleAttribute) {
errors.push('A global Table that uses PROVISIONED as the billing mode needs auto-scaled write capacity. ' +
'Use the autoScaleWriteCapacity() method to enable it.');
} else if (!writeAutoScaleAttribute._scalingPolicyCreated) {
errors.push('A global Table that uses PROVISIONED as the billing mode needs auto-scaled write capacity with a policy. ' +
'Call one of the scaleOn*() methods of the object returned from autoScaleWriteCapacity()');
}
}

return errors;
}

Expand Down Expand Up @@ -1467,6 +1482,7 @@ export class Table extends TableBase {
onEventHandlerPolicy.policy,
isCompleteHandlerPolicy.policy,
);
this.globalReplicaCustomResources.push(currentRegion);

// Deploy time check to prevent from creating a replica in the region
// where this stack is deployed. Only needed for environment agnostic
Expand Down Expand Up @@ -1681,4 +1697,4 @@ class SourceTableAttachedPrincipal extends iam.PrincipalBase {
statementAdded: true,
};
}
}
}
119 changes: 95 additions & 24 deletions packages/@aws-cdk/aws-dynamodb/test/dynamodb.test.ts
Expand Up @@ -836,27 +836,40 @@ test('when specifying PAY_PER_REQUEST billing mode', () => {
);
});

test('error when specifying read or write capacity with a PAY_PER_REQUEST billing mode', () => {
const stack = new Stack();
expect(() => new Table(stack, 'Table A', {
tableName: TABLE_NAME,
billingMode: BillingMode.PAY_PER_REQUEST,
partitionKey: TABLE_PARTITION_KEY,
readCapacity: 1,
})).toThrow(/PAY_PER_REQUEST/);
expect(() => new Table(stack, 'Table B', {
tableName: TABLE_NAME,
billingMode: BillingMode.PAY_PER_REQUEST,
partitionKey: TABLE_PARTITION_KEY,
writeCapacity: 1,
})).toThrow(/PAY_PER_REQUEST/);
expect(() => new Table(stack, 'Table C', {
tableName: TABLE_NAME,
billingMode: BillingMode.PAY_PER_REQUEST,
partitionKey: TABLE_PARTITION_KEY,
readCapacity: 1,
writeCapacity: 1,
})).toThrow(/PAY_PER_REQUEST/);
describe('when billing mode is PAY_PER_REQUEST', () => {
let stack: Stack;

beforeEach(() => {
stack = new Stack();
});

test('creating the Table fails when readCapacity is specified', () => {
expect(() => new Table(stack, 'Table A', {
tableName: TABLE_NAME,
partitionKey: TABLE_PARTITION_KEY,
billingMode: BillingMode.PAY_PER_REQUEST,
readCapacity: 1,
})).toThrow(/PAY_PER_REQUEST/);
});

test('creating the Table fails when writeCapacity is specified', () => {
expect(() => new Table(stack, 'Table B', {
tableName: TABLE_NAME,
partitionKey: TABLE_PARTITION_KEY,
billingMode: BillingMode.PAY_PER_REQUEST,
writeCapacity: 1,
})).toThrow(/PAY_PER_REQUEST/);
});

test('creating the Table fails when both readCapacity and writeCapacity are specified', () => {
expect(() => new Table(stack, 'Table C', {
tableName: TABLE_NAME,
partitionKey: TABLE_PARTITION_KEY,
billingMode: BillingMode.PAY_PER_REQUEST,
readCapacity: 1,
writeCapacity: 1,
})).toThrow(/PAY_PER_REQUEST/);
});
});

test('when adding a global secondary index with hash key only', () => {
Expand Down Expand Up @@ -2766,12 +2779,62 @@ describe('global', () => {
});
});

test('throws with PROVISIONED billing mode', () => {
test('throws when PROVISIONED billing mode is used without auto-scaled writes', () => {
// GIVEN
const stack = new Stack();

// WHEN
new Table(stack, 'Table', {
partitionKey: {
name: 'id',
type: AttributeType.STRING,
},
replicationRegions: [
'eu-west-2',
'eu-central-1',
],
billingMode: BillingMode.PROVISIONED,
});

// THEN
expect(() => {
SynthUtils.synthesize(stack);
}).toThrow(/A global Table that uses PROVISIONED as the billing mode needs auto-scaled write capacity/);
});

test('throws when PROVISIONED billing mode is used with auto-scaled writes, but without a policy', () => {
// GIVEN
const stack = new Stack();

// WHEN
const table = new Table(stack, 'Table', {
partitionKey: {
name: 'id',
type: AttributeType.STRING,
},
replicationRegions: [
'eu-west-2',
'eu-central-1',
],
billingMode: BillingMode.PROVISIONED,
});
table.autoScaleWriteCapacity({
minCapacity: 1,
maxCapacity: 10,
});

// THEN
expect(() => new Table(stack, 'Table', {
expect(() => {
SynthUtils.synthesize(stack);
}).toThrow(/A global Table that uses PROVISIONED as the billing mode needs auto-scaled write capacity with a policy/);
});

test('allows PROVISIONED billing mode when auto-scaled writes with a policy are specified', () => {
// GIVEN
const stack = new Stack();

// WHEN
const table = new Table(stack, 'Table', {
partitionKey: {
name: 'id',
type: AttributeType.STRING,
Expand All @@ -2781,7 +2844,15 @@ describe('global', () => {
'eu-central-1',
],
billingMode: BillingMode.PROVISIONED,
})).toThrow(/`PAY_PER_REQUEST`/);
});
table.autoScaleWriteCapacity({
minCapacity: 1,
maxCapacity: 10,
}).scaleOnUtilization({ targetUtilizationPercent: 75 });

expect(stack).toHaveResourceLike('AWS::DynamoDB::Table', {
BillingMode: ABSENT, // PROVISIONED is the default
});
});

test('throws when stream is set and not set to NEW_AND_OLD_IMAGES', () => {
Expand Down