Skip to content

fix(amplify-table): populate per-GSI provisionedThroughput on billing-mode update to prevent null capacity UPDATE_FAILED - #3518

Merged
Simone319 merged 1 commit into
mainfrom
fix/gsi-update-provisioned-throughput-per-index
Jul 31, 2026
Merged

fix(amplify-table): populate per-GSI provisionedThroughput on billing-mode update to prevent null capacity UPDATE_FAILED#3518
Simone319 merged 1 commit into
mainfrom
fix/gsi-update-provisioned-throughput-per-index

Conversation

@Simone319

@Simone319 Simone319 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Problem

All five replace_2_gsis_update_attr_* deploy-velocity CDK e2e groups (empty_table, single_record, 1k_records, 10k_records, 100k_records) fail deterministically at deploy time. The Custom::AmplifyDynamoDBTable resource goes to UPDATE_FAILED with:

2 validation errors detected:
Value null at 'globalSecondaryIndexUpdates.1.member.update.provisionedThroughput.writeCapacityUnits'
  failed to satisfy constraint: Member must not be null;
Value null at 'globalSecondaryIndexUpdates.1.member.update.provisionedThroughput.readCapacityUnits'
  failed to satisfy constraint: Member must not be null

These tests replace two GSIs while flipping the table to BillingMode.PROVISIONED with a table-level throughput of 10/10 (API_POST_PROCESSOR_SET_PROVISIONED_THROUGHPUT_TWO_GSIS).

Root cause

In amplify-table-manager-handler.ts, the GSI throughput-update path of getNextGSIUpdate() sourced capacity units exclusively from the end-state index definition:

ProvisionedThroughput: {
  ReadCapacityUnits: gsiToUpdate.provisionedThroughput?.readCapacityUnits!,
  WriteCapacityUnits: gsiToUpdate.provisionedThroughput?.writeCapacityUnits!,
},

In the managed-table construct, a GSI only carries its own provisionedThroughput when the construct-level billing mode is already PROVISIONED (amplify-dynamodb-table-construct/index.ts L143-149). In this scenario the indexes inherit the table-level throughput and therefore have no provisionedThroughput of their own, so both units evaluated to undefined. The non-null assertions (!) suppressed the type error, and because this return value is not passed through parsePropertiesToDynamoDBInput the undefined keys survived to the SDK call, where DynamoDB saw an empty provisionedThroughput object and reported both members as null.

gsiRequiresUpdatePredicate made the mirror-image mistake, comparing the live index capacity against the index end state:

currentStateGSI.ProvisionedThroughput?.ReadCapacityUnits !== endStateGSI.provisionedThroughput?.readCapacityUnits

That is 10 !== undefined → always true, so the handler kept selecting an index that did not actually need an update, guaranteeing the malformed request was issued on every reconciliation pass.

Separately, the billing-mode-change branch of getNextAtomicUpdate() had the opposite gap: it mapped every current-state GSI to a table-level endState.provisionedThroughput, clobbering any per-index throughput a customer had configured (and emitting the same empty object whenever the table-level value was absent).

The GSI creation path already resolved this correctly — index value first, table-level as the default (previously L677-684). The update paths simply never adopted that logic.

Fix

Extract the creation path's resolution into a single helper and apply it uniformly across all three paths:

const resolveGsiProvisionedThroughput = (endState, indexEndState) => {
  if (endState.billingMode === 'PAY_PER_REQUEST') return undefined;
  const candidate = indexEndState?.provisionedThroughput ?? endState.provisionedThroughput;
  if (candidate?.readCapacityUnits === undefined || candidate?.writeCapacityUnits === undefined) return undefined;
  return { readCapacityUnits: candidate.readCapacityUnits, writeCapacityUnits: candidate.writeCapacityUnits };
};
  • Per-index precedence with table-level fallback — each index gets its own declared throughput, or the table-level default when it declares none.
  • All-or-nothing — the helper returns undefined rather than a partially populated object, so a ProvisionedThroughput with null/absent capacity can never be serialized. Indexes with no resolvable throughput are filtered out of GlobalSecondaryIndexUpdates instead of emitting an invalid member.
  • Unsafe non-null assertions removed (previously L729-735).
  • PAY_PER_REQUEST preserved — returns undefined, so on-demand tables continue to omit the property entirely.
  • Correct predicate — comparing against the resolved throughput means an index that already matches the table-level default is no longer selected for a spurious update.

Change is localized to getNextAtomicUpdate / getNextGSIUpdate plus the new helper. No public API surface is touched (this package has no api-extractor.json), so no API.md update is required.

Why now

The defect has been latent since #1940. The affected e2e groups were green on main on Jul 10 (758ab96) and began failing deterministically from Jul 24 onward, while the handler code is byte-identical across that window. The trigger is therefore external — service-side UpdateTable validation tightening that stopped tolerating an empty provisionedThroughput on an Update action — rather than a repo change. This fix makes the handler correct regardless of the external trigger.

Validation

  • npx tsc --build on amplify-graphql-model-transformer — clean.
  • Full package unit suite: 196/196 passing, 87 snapshots.
  • Four new unit tests in amplify-table-manager-lambda.test.ts under Get billing mode update › per-index provisioned throughput:
    • billing mode flips to PROVISIONED with two GSIs where only one declares its own throughput → asserts both Update.ProvisionedThroughput entries have non-null numeric read/write capacity (gsi1 keeps 3/4, gsi2 inherits 10/10).
    • regression case: an existing GSI with no declared throughput now falls back to the table-level value instead of emitting nulls — this reproduced the UPDATE_FAILED scenario and now passes.
    • no resolvable throughput → no GSI update emitted.
    • PAY_PER_REQUESTProvisionedThroughput omitted.
  • One existing snapshot intentionally updated: a GSI declaring its own 4/4 throughput is no longer overwritten with the table-level 5/5 during a billing-mode change. This is the behavioral correction, not a regression.
  • CodeBuild e2e (e2e_workflow_cdk.yml, which contains the replace_2_gsis_update_attr_* groups) plus pr_workflow.yml were triggered on this branch and have completed — see CI status below.

CI status

The groups this PR targets are PASSING.

  • All five replace_2_gsis_update_attr_* groups (empty_table, single_record, 1k_records, 10k_records, 100k_records) are green on the e2e CDK batch, with zero recurrence of the provisionedThroughput null-capacity UPDATE_FAILED.
  • The related 3_gsis_* set is green as well — no regression from the per-index throughput resolution change.
  • The pr_workflow gate is green.

The two remaining red e2e groups are unrelated to this PR

custom_query_mutation_extension and admin_role fail for a different, pre-existing reason that has nothing to do with this change:

No file touched by this PR is involved in those two failures.

Notes

Independent of #3517 (addResourceDependency deprecation) — branched off latest main, no shared files.

…-mode update to prevent null capacity UPDATE_FAILED

The Custom::AmplifyDynamoDBTable managed-table handler could emit a
GlobalSecondaryIndexUpdates[*].Update.ProvisionedThroughput with absent
read/write capacity units, which DynamoDB UpdateTable rejects with:

  2 validation errors detected: Value null at
  'globalSecondaryIndexUpdates.1.member.update.provisionedThroughput.writeCapacityUnits'
  failed to satisfy constraint: Member must not be null (and readCapacityUnits)

leaving the custom resource in UPDATE_FAILED.

Root cause: in getNextGSIUpdate(), the GSI throughput-update path sourced
capacity exclusively from the end-state *index* definition
(gsiToUpdate.provisionedThroughput?.readCapacityUnits!). When an index
inherits the table-level throughput it has no provisionedThroughput of its
own, so both units were `undefined` and the non-null assertions hid it at
compile time. The predicate that selected the index made the same mistake in
reverse: it compared the live index capacity against the *index* end state,
so `10 !== undefined` was always true and it kept selecting an index that did
not actually need an update. The billing-mode-change path in
getNextAtomicUpdate() had the mirror-image gap, sourcing every index's
capacity from the table-level end state and thereby clobbering any per-index
throughput the customer had configured.

The GSI *creation* path already resolved this correctly (index value first,
table-level as default). This change extracts that resolution into
resolveGsiProvisionedThroughput() and applies it uniformly to the creation,
billing-mode-change, and throughput-update paths. The helper returns
undefined rather than a partially populated object, so an incomplete
ProvisionedThroughput can no longer be serialized, and it returns undefined
for PAY_PER_REQUEST so on-demand tables keep omitting the property.

The defect has been latent since #1940. The replace_2_gsis_update_attr_*
deploy-velocity e2e groups were green on main Jul 10 (758ab96) and began
failing deterministically Jul 24+ with the handler code byte-identical
across that window, so an external trigger (service-side UpdateTable
validation tightening) unmasked it rather than a repo change.

Also updates one existing snapshot: a GSI declaring its own 4/4 throughput
is no longer overwritten with the table-level 5/5 on a billing-mode change.
@Simone319

Copy link
Copy Markdown
Contributor Author

CI status clarification

The tests this PR fixes are passing. All five replace_2_gsis_update_attr_* groups (empty_table, single_record, 1k_records, 10k_records, 100k_records) are green on the e2e CDK batch, with zero recurrence of the provisionedThroughput null-capacity UPDATE_FAILED. The 3_gsis_* set is green too, and the pr_workflow gate is green.

The two still-red e2e groups are not caused by this PR. custom_query_mutation_extension and admin_role fail for a different, pre-existing reason: unpinned cdk init CLI toolchain drift in the e2e scaffolder. The upstream CDK app template changed its synth command from npx ts-node --prefer-ts-exts to npx tsc && npx tsx, which turns synth into a whole-project typecheck of every .ts copied into the scratch project — including lambda entry files that are only referenced by esbuild as path strings and never imported. Result: TS7006 (authorizer.ts:1:26) and TS2307 (apiInvoker.ts:6:51), both failing before cdk synth runs.

This affects main and every open PR, is deterministic (reproduced 6/6 locally), and will never clear on retry. No file touched by this PR is involved.

It is being fixed separately in #3519#3519

The description has been updated with a CI status section reflecting the above.

@Simone319
Simone319 marked this pull request as ready for review July 31, 2026 10:14
@Simone319
Simone319 requested a review from a team as a code owner July 31, 2026 10:14
@Simone319
Simone319 merged commit 354c33b into main Jul 31, 2026
7 of 8 checks passed
@Simone319
Simone319 deleted the fix/gsi-update-provisioned-throughput-per-index branch July 31, 2026 10:15
writeCapacityUnits: endState.provisionedThroughput?.writeCapacityUnits,
};
}
const gsiProvisionThroughput: any = resolveGsiProvisionedThroughput(endState, gsiToAdd);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we drop the : any here? The helper already returns a well-typed union — keeping it inferrable makes the downstream spread type-safe if the return type ever changes.

};
const gsiToUpdate = endStateGSIs.find(gsiRequiresUpdatePredicate);
if (gsiToUpdate) {
const resolvedThroughput = resolveGsiProvisionedThroughput(endState, gsiToUpdate)!;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth removing the ! here too? The predicate already guarantees it's defined, but given the PR's goal of eliminating non-null assertions, a guard clause (or capturing the resolved value inside the loop) would keep things consistent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants