Skip to content

AWS::DynamoDB::GlobalTable: explicit SDK-shaped throughput blocks skip coercion, suppress derived siblings, and ignore billing mode #1428

Description

@go-to-k

Attempted during #1422 (issue #1387) and deliberately reverted. This issue records the problem, three designs that were each found broken in review, and what a correct fix has to satisfy — so the next attempt does not rediscover them.

The problem

toSdkGlobalSecondaryIndexes / toSdkReplicaGlobalSecondaryIndexes in src/provisioning/providers/dynamodb-globaltable-provider.ts forward an explicitly-supplied, already-SDK-shaped throughput block verbatim:

if (explicitProvisioned) {
  sdk.ProvisionedThroughput = explicitProvisioned as unknown as ProvisionedThroughput;
} else if (billingMode === 'PROVISIONED') { /* derive */ }

Four sites: GSI ProvisionedThroughput / OnDemandThroughput, replica ProvisionedThroughputOverride / OnDemandThroughputOverride.

Three defects follow from that shape:

  1. No numeric coercion. This is the ONE path that skips toFiniteNumber, so a stringly-typed CFn "5" reaches the SDK unnormalized while every derived value is a number.
  2. Whole-block replacement. A PARTIAL explicit block suppresses valid derived siblings — e.g. an explicit {MaxReadRequestUnits: 41} discards a perfectly good WriteOnDemandThroughputSettings.MaxWriteRequestUnits from the same template.
  3. No billing-mode gate. The if (explicit…) branch fires regardless of billingMode, so an explicit ProvisionedThroughput is emitted on a PAY_PER_REQUEST table and vice versa. AWS rejects both.

Reachability

The CFn schema forbids these members, so CDK never emits them. Reachable only via pre-#1387 cdkd state and hand-authored templates — the latter being a first-class cdkd surface (cdkd import --migrate-from-cloudformation, cdkd migrate --from-cfn-stack).

Three designs that were tried and are WRONG

Each passed the author's own review and was caught by an independent reviewer.

v1 — coerce, return a partial block, assign it whole. Broke two ways: a partial explicit block still suppressed derived siblings (defect 2 unfixed), and an all-unparseable block became {}. AWS documents an EMPTY ProvisionedThroughputOverride / OnDemandThroughputOverride as "inherit the source table's settings", so that turned a loud failure into a silent inherit. On the GSI side {} additionally suppressed the derived fallback.

v2 — return undefined when nothing coerces, fall through to derived. Fixed the empty-block inherit, but a fully-unparseable block then fell into the derived branch, which defaults to DEFAULT_CAPACITY_UNITS (5). A table the template explicitly sized was silently deployed at 5/5 — a throttling symptom days later, not an error. Partial-suppression still unfixed.

v3 — throw on a present-but-uncoercible member; merge partial blocks per member. Fixed 1 and 2, and was mutation-proofed. Still broken:

  • Unfixable state. The helper is also called on previousProperties (from cdkd state) at the billing-flip and GSI-diff sites. A garbage value already in state made EVERY subsequent update() throw — including the deploy that would have removed it. No template edit escapes it; the user would have to hand-edit S3 state. Pre-change that deploy succeeded.
  • Throw after CreateTable. The replica path runs inside the post-CreateTable wiring, so a statically-detectable garbage override created a real table and then best-effort-deleted it. Under DeletionProtectionEnabled: true that delete fails, orphaning a table with no state record.
  • toFiniteNumber uses bare Number(), so [] → 0, true → 1, '0x10' → 16 slip through without throwing — and [] → 0 flips the presence gate on. Only {Ref: …}-style intrinsics actually threw.

What a correct fix must satisfy

  • Coerce numerics, merge per member over derived values, AND gate on billing mode. Fixing any subset leaves a live defect.
  • Be asymmetric between the desired side and the previous side: strict handling may apply to the template, never to previousProperties, or bad state becomes unfixable.
  • Validate BEFORE any mutating AWS call, not inside post-CreateTable wiring.
  • Tighten the value predicate (typeof value === 'number' | 'string') rather than relying on Number().
  • Prefer warn-and-fall-back over throw unless the loudness is genuinely required; three of the defects above exist only because a throw was introduced.

Related: #1427 (a separate divergence in the same derivation — cdkd takes the literal ReadCapacityUnits where CFn may take max(literal, SeedCapacity)).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions