Skip to content

Commit ad56c62

Browse files
committed
fix(dynamodb): do not send an empty coerced throughput block, which AWS reads as inherit
The numeric coercion added in the previous commit returned an empty object when no member parsed, and the call sites assigned it. AWS documents an empty ProvisionedThroughputOverride / OnDemandThroughputOverride as inherit the source table's settings, so an unparseable explicit value silently changed replica behavior instead of failing loudly. On the GSI side an empty block also suppressed the derived fallback, letting a garbage explicit value beat a valid derived one. The helper now returns undefined when nothing survives coercion, and every call site treats that as no explicit block and falls through. Found by review; both directions are bound by tests. Also moves the toFiniteNumber doc comment back onto toFiniteNumber, drops two redundant casts, and rewords the UpdateTable comments so the flip reads as the one case where BillingMode and GlobalSecondaryIndexUpdates combine rather than as an exception to the ReplicaUpdates rule.
1 parent db3bc07 commit ad56c62

2 files changed

Lines changed: 88 additions & 32 deletions

File tree

src/provisioning/providers/dynamodb-globaltable-provider.ts

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ import type {
8383
* - The serialization is load-bearing: AWS's `UpdateTable` does not accept
8484
* `ReplicaUpdates` alongside `BillingMode` / `GlobalSecondaryIndexUpdates`,
8585
* so each category is its own SDK round-trip with a wait-for-ACTIVE in
86-
* between. The ONE documented exception is the `PAY_PER_REQUEST ->
87-
* PROVISIONED` flip, where AWS REQUIRES per-GSI `ProvisionedThroughput` in
88-
* the SAME call as `BillingMode` ("you must specify read and write capacity
89-
* unit values for the table and for each global secondary index") — so that
90-
* path deliberately sends both together (Issue #1387). Immutable property
91-
* changes (TableName, KeySchema,
86+
* between. `BillingMode` and `GlobalSecondaryIndexUpdates` DO combine, and
87+
* only in the `PAY_PER_REQUEST -> PROVISIONED` flip, where AWS REQUIRES
88+
* per-GSI `ProvisionedThroughput` in the SAME call as `BillingMode` ("you
89+
* must specify read and write capacity unit values for the table and for
90+
* each global secondary index") — so that path deliberately sends both
91+
* together (Issue #1387). Immutable property changes (TableName, KeySchema,
9292
* AttributeDefinitions removal, LocalSecondaryIndexes) throw
9393
* `ProvisioningError` naming the offending field — the deploy engine's
9494
* diff classification should catch these as REPLACEMENT before ever
@@ -654,9 +654,10 @@ export class DynamoDBGlobalTableProvider implements ResourceProvider {
654654
* AWS-side state-machine constraint: `UpdateTable` does not accept
655655
* `ReplicaUpdates` alongside `BillingMode` / `GlobalSecondaryIndexUpdates`,
656656
* so each category must serialize into its own SDK round-trip with a
657-
* `waitForTableActiveAfterUpdate` between every step. The ONE exception is
658-
* step 4's `PAY_PER_REQUEST -> PROVISIONED` flip, which AWS requires to
659-
* carry per-GSI `ProvisionedThroughput` in the same call (Issue #1387).
657+
* `waitForTableActiveAfterUpdate` between every step. `BillingMode` and
658+
* `GlobalSecondaryIndexUpdates` DO combine, and only in step 4's
659+
* `PAY_PER_REQUEST -> PROVISIONED` flip, which AWS requires to carry
660+
* per-GSI `ProvisionedThroughput` in the same call (Issue #1387).
660661
* Order:
661662
* 1. Wait for current ACTIVE (defensive).
662663
* 2. Tags diff (TagResource / UntagResource — no wait needed).
@@ -2785,7 +2786,6 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
27852786
: undefined;
27862787
}
27872788

2788-
/** Coerce a CFn numeric (CFn is stringly-typed) to a finite number. */
27892789
/**
27902790
* Normalize an explicitly-supplied, already-SDK-shaped throughput block.
27912791
*
@@ -2796,19 +2796,32 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
27962796
* coerced — an inconsistency that only shows up on the rarest input. Picking
27972797
* the known members explicitly also drops junk keys the SDK serializer would
27982798
* discard silently anyway.
2799+
*
2800+
* Returns `undefined` — NOT an empty object — when no member survives
2801+
* coercion. That distinction is load-bearing on the replica overrides: AWS
2802+
* documents an EMPTY `{Provisioned,OnDemand}ThroughputOverride` as "inherit
2803+
* the source table's settings", so assigning `{}` for an unparseable value
2804+
* would turn a loud serialization failure into a silent inherit, and on the
2805+
* GSI side would additionally suppress the derived fallback. Callers treat
2806+
* `undefined` as "no explicit block" and fall through.
27992807
*/
28002808
function coerceThroughputNumbers<K extends string>(
28012809
block: Record<string, unknown>,
28022810
members: readonly K[]
2803-
): Record<K, number | undefined> {
2804-
const out = {} as Record<K, number | undefined>;
2811+
): Record<K, number> | undefined {
2812+
const out = {} as Record<K, number>;
2813+
let any = false;
28052814
for (const member of members) {
28062815
const n = toFiniteNumber(block[member]);
2807-
if (n !== undefined) out[member] = n;
2816+
if (n !== undefined) {
2817+
out[member] = n;
2818+
any = true;
2819+
}
28082820
}
2809-
return out;
2821+
return any ? out : undefined;
28102822
}
28112823

2824+
/** Coerce a CFn numeric (CFn is stringly-typed) to a finite number. */
28122825
function toFiniteNumber(value: unknown): number | undefined {
28132826
if (value === undefined || value === null || value === '') return undefined;
28142827
const n = Number(value);
@@ -2963,13 +2976,20 @@ export function toSdkGlobalSecondaryIndexes(
29632976
sdk.WarmThroughput = gsi['WarmThroughput'] as GlobalSecondaryIndex['WarmThroughput'];
29642977
}
29652978

2966-
const explicitProvisioned = asRecord(gsi['ProvisionedThroughput']);
2967-
const explicitOnDemand = asRecord(gsi['OnDemandThroughput']);
2979+
const explicitProvisioned = asRecord(gsi['ProvisionedThroughput'])
2980+
? coerceThroughputNumbers(asRecord(gsi['ProvisionedThroughput'])!, [
2981+
'ReadCapacityUnits',
2982+
'WriteCapacityUnits',
2983+
])
2984+
: undefined;
2985+
const explicitOnDemand = asRecord(gsi['OnDemandThroughput'])
2986+
? coerceThroughputNumbers(asRecord(gsi['OnDemandThroughput'])!, [
2987+
'MaxReadRequestUnits',
2988+
'MaxWriteRequestUnits',
2989+
])
2990+
: undefined;
29682991
if (explicitProvisioned) {
2969-
sdk.ProvisionedThroughput = coerceThroughputNumbers(explicitProvisioned, [
2970-
'ReadCapacityUnits',
2971-
'WriteCapacityUnits',
2972-
]) as ProvisionedThroughput;
2992+
sdk.ProvisionedThroughput = explicitProvisioned;
29732993
} else if (billingMode === 'PROVISIONED') {
29742994
sdk.ProvisionedThroughput = {
29752995
ReadCapacityUnits:
@@ -2983,10 +3003,7 @@ export function toSdkGlobalSecondaryIndexes(
29833003
}
29843004

29853005
if (explicitOnDemand) {
2986-
sdk.OnDemandThroughput = coerceThroughputNumbers(explicitOnDemand, [
2987-
'MaxReadRequestUnits',
2988-
'MaxWriteRequestUnits',
2989-
]) as OnDemandThroughput;
3006+
sdk.OnDemandThroughput = explicitOnDemand;
29903007
} else if (billingMode !== 'PROVISIONED') {
29913008
const maxWrite = toFiniteNumber(
29923009
asRecord(gsi['WriteOnDemandThroughputSettings'])?.['MaxWriteRequestUnits']
@@ -3039,25 +3056,30 @@ export function toSdkReplicaGlobalSecondaryIndexes(
30393056
const sdk: ReplicaGlobalSecondaryIndex = {
30403057
IndexName: cfn['IndexName'] as string | undefined,
30413058
};
3042-
const explicitProvisioned = asRecord(cfn['ProvisionedThroughputOverride']);
3043-
const explicitOnDemand = asRecord(cfn['OnDemandThroughputOverride']);
3059+
// Coerced to `undefined` when nothing parses: AWS reads an EMPTY override
3060+
// as "inherit the source table's settings", so assigning `{}` here would
3061+
// silently change replica behavior instead of failing loudly.
3062+
const explicitProvisionedRaw = asRecord(cfn['ProvisionedThroughputOverride']);
3063+
const explicitOnDemandRaw = asRecord(cfn['OnDemandThroughputOverride']);
3064+
const explicitProvisioned = explicitProvisionedRaw
3065+
? coerceThroughputNumbers(explicitProvisionedRaw, ['ReadCapacityUnits'])
3066+
: undefined;
3067+
const explicitOnDemand = explicitOnDemandRaw
3068+
? coerceThroughputNumbers(explicitOnDemandRaw, ['MaxReadRequestUnits'])
3069+
: undefined;
30443070
const readCapacity = deriveReadCapacityUnits(
30453071
asRecord(cfn['ReadProvisionedThroughputSettings'])
30463072
);
30473073
const maxReadRequestUnits = toFiniteNumber(
30483074
asRecord(cfn['ReadOnDemandThroughputSettings'])?.['MaxReadRequestUnits']
30493075
);
30503076
if (explicitProvisioned) {
3051-
sdk.ProvisionedThroughputOverride = coerceThroughputNumbers(explicitProvisioned, [
3052-
'ReadCapacityUnits',
3053-
]);
3077+
sdk.ProvisionedThroughputOverride = explicitProvisioned;
30543078
} else if (readCapacity !== undefined) {
30553079
sdk.ProvisionedThroughputOverride = { ReadCapacityUnits: readCapacity };
30563080
}
30573081
if (explicitOnDemand) {
3058-
sdk.OnDemandThroughputOverride = coerceThroughputNumbers(explicitOnDemand, [
3059-
'MaxReadRequestUnits',
3060-
]);
3082+
sdk.OnDemandThroughputOverride = explicitOnDemand;
30613083
} else if (maxReadRequestUnits !== undefined) {
30623084
sdk.OnDemandThroughputOverride = { MaxReadRequestUnits: maxReadRequestUnits };
30633085
}

tests/unit/provisioning/dynamodb-globaltable-provider-gsi-throughput.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,40 @@ describe('DynamoDBGlobalTable GSI throughput translation (issue #1387)', () => {
437437
});
438438
});
439439

440+
it('falls through to the derived value when an explicit block coerces to nothing', () => {
441+
// An explicit block whose every member is unparseable must NOT become an
442+
// empty object: AWS reads an empty throughput block as "inherit", which
443+
// would turn a loud failure into a silent wrong value, and on the GSI
444+
// side it would also beat a perfectly valid derived setting.
445+
const [gsi] = toSdkGlobalSecondaryIndexes(
446+
{
447+
GlobalSecondaryIndexes: [
448+
{
449+
IndexName: 'garbage',
450+
KeySchema: [{ AttributeName: 'g', KeyType: 'HASH' }],
451+
Projection: { ProjectionType: 'ALL' },
452+
OnDemandThroughput: { MaxReadRequestUnits: 'abc' },
453+
ReadOnDemandThroughputSettings: { MaxReadRequestUnits: 41 },
454+
WriteOnDemandThroughputSettings: { MaxWriteRequestUnits: 42 },
455+
},
456+
],
457+
},
458+
'us-east-1',
459+
'PAY_PER_REQUEST'
460+
);
461+
expect(gsi!.OnDemandThroughput).toEqual({
462+
MaxReadRequestUnits: 41,
463+
MaxWriteRequestUnits: 42,
464+
});
465+
});
466+
467+
it('leaves a replica override unset when it coerces to nothing, rather than sending an inherit-me empty block', () => {
468+
const [replica] = toSdkReplicaGlobalSecondaryIndexes([
469+
{ IndexName: 'r', OnDemandThroughputOverride: { MaxReadRequestUnits: 'abc' } },
470+
])!;
471+
expect(replica!.OnDemandThroughputOverride).toBe(undefined);
472+
});
473+
440474
it('throws on a non-array GlobalSecondaryIndexes instead of deploying a table with none', () => {
441475
// Absent is legitimately empty; present-but-not-an-array (an unresolved
442476
// intrinsic) previously collapsed to [] and created the table with ZERO

0 commit comments

Comments
 (0)