fix(dynamodb): register per-index auto-scaling and create GlobalTables at MinCapacity - #1451
Merged
Merged
Conversation
Two GlobalTable defects that had to ship together, per #1435's own "do NOT fix this in isolation" note: creating at MinCapacity while no scaling policy exists would pin the table at min forever. - no `dynamodb:index:*` dimension was ever registered, so a per-GSI `Capacity.autoscaled(...)` produced a correct INITIAL capacity and then dropped Min/Max/TargetTracking - the index never scaled. The #1387 integ asserted that initial value and passed, which is why this read as working. - `create()` never called `applyAutoScalingDiff` at all, so a fresh PROVISIONED table had no policy until some later deploy ran an update. - the LOCAL replica's read dimension was registered by no path at all: `update()`'s replica loops all `continue` on the deploy region. `applyAutoScalingDiff` now covers all four DynamoDB scalable dimensions (index targets use `table/<t>/index/<i>`; policy names keep AWS's `<metricType>:<resourceId>` convention so table-level names stay byte-identical and no deployed table orphans its policy). A pure `collectAutoScalingTargets` walks the four asymmetric CFn sources, and `create()` / a new `update()` step 6b / `delete()` reconcile from it. Desired targets are upserted unconditionally rather than diff-gated - the calls are idempotent, and a diff check would never backfill the tables this issue is about, whose settings are identical on both sides of every later deploy. `SeedCapacity ?? MinCapacity` chain, but CloudFormation's precedence is context-dependent. Live-verified against a real CFn stack (CdkdIssue1427Control, us-east-1): MinCapacity 1 / SeedCapacity 20 reached CREATE_COMPLETE at WriteCapacityUnits 1 on both table and index, with NumberOfDecreasesToday 0 ruling out a scale-down. AWS documents SeedCapacity only for the billing-mode transition. The helpers now take a `CapacitySource`, `'seed'` at the three PAY_PER_REQUEST -> PROVISIONED flip call sites and `'min'` everywhere else, so cdkd stops over-provisioning every autoscaled PROVISIONED GlobalTable by the seed-to-min ratio. Tests: 14 new unit tests plus 5 reworked capacity-precedence ones; the three update() tests are mutation-proofed (disabling step 6b fails exactly those three). verify.sh gains step 4c (per-index target + policy, asserted against the BASELINE deploy so it pins the create-side half), step 12a (local replica read dimension) and step 16a2 (the index target is deregistered by destroy - application-autoscaling is a separate control plane, so DeleteTable alone leaves an orphan a future same-named table inherits). Closes #1419 Closes #1435
Blocker: step 6b confined itself to the two never-registered dimensions via a STATIC filter, handing table-level write and cross-region read back to their existing diff gates. Those gates are exactly the ones the backfill argument says never fire on a pre-fix table (an unchanged replica is not even visited by diffReplicas), so the static filter permanently excluded two dimensions from the fix it was part of. 6b now covers all four; double-application is avoided with a DYNAMIC skip-set of what earlier steps applied during THIS update, so a dimension whose gate declined is still backfilled. Cost: re-asserting every target on every deploy is 2 x (1 + N_gsi x (1 + N_replica)) serial calls -- over a hundred round trips on a 20-GSI, 3-replica table. Presence is now probed with one batched DescribeScalableTargets per region and an already-present, unchanged target is skipped. A failed probe means presence is unknown and everything is upserted, which is the correct direction to fail. Silent gap under load: every error in this path is swallowed into a WARN, so an un-retried ThrottlingException would leave a target unregistered without a trace -- the same never-registered gap, recreated by the burst this change introduces. RegisterScalableTarget and PutScalingPolicy now carry a throttle-only retry. Create-side leak: the partial-create cleanup deletes the table directly rather than routing through delete(), so a target registered before a LATER wiring step failed was orphaned with no table left to name it. Registration is now the last wiring step and is wrapped so a best-effort concern can never destroy a successfully created table. Cross-region teardown leak: index names came from the replica's own GlobalSecondaryIndexes, which AWS may omit for a replica that inherits throughput (ProvisionedThroughputOverride is documented "if not described, uses the source table's"). Index names are identical across replicas, so the table's list is the correct source. Index readiness: a GSI added by the same deploy leaves the TABLE ACTIVE while the index is still CREATING, and application-autoscaling rejects a target whose resource is not ready. Step 6b waits for index readiness first -- best-effort, since a miss self-heals on the next deploy but a throw would fail a deploy whose resources are correct. Also: dropped a dangling duplicate JSDoc block and corrected two comments that still claimed SeedCapacity-before-MinCapacity. Tests: +6. The identical-on-both-sides roundtrip test encoded the OLD contract (unchanged template means no calls) and is replaced by two tests pinning the new one -- no-op when registered, BACKFILL when not. New coverage for the create-side ordering, the create-side guard, the cross-region index teardown, ObjectNotFound suppression, and the table-level seed context (which no fixture in the tree pinned). Integ: the fixture GSI's read capacity becomes autoscaled so the fourth dimension gets real-AWS coverage (minCapacity 7 keeps step 4b's existing assertion intact), the table-level write gains a seedCapacity differing from its min so #1435 is discriminating at table level too, and step 16a2 asserts all four dimensions are deregistered rather than just the index write one.
go-to-k
force-pushed
the
fix/1419-1435-globaltable-index-autoscaling
branch
from
August 9, 2026 19:32
feaa0b6 to
ab172f3
Compare
Re-review of the previous round found the retry was a complete no-op that typechecked. `withRetry`'s `isRetryable` is called as `(message, error)`; passing `isThrottlingError` directly handed it the message STRING, on which it walks no `.name` / `.$metadata` / `.cause` and always returns false. And because `isRetryable` was set at all, the call ALSO opted out of the default schedule -- so behaviour was identical to not wrapping in `withRetry` at all. A 1-arg `unknown` callback is assignable to the 2-arg signature, so nothing complained. Now spelled the way `describe-type.ts` already does it, and pinned by a test that fails against the old form (verified by re-introducing it). The presence probe gained the other half of its question. It checked only that a scalable TARGET existed, but a target whose PutScalingPolicy failed -- which `applyAutoScalingDiff` swallows into a WARN -- scales nothing, and probing the target alone would call it present and skip it on every later deploy. That is the same silent never-scales gap this change exists to close, one level down. It now requires a target AND a target-tracking policy, and paginates both reads (a page caps well below the batch size, so reading page 1 only reported absent for exactly the wide tables the probe exists to speed up). Batch size dropped 100 -> 50 to stay under the documented per-page ceiling rather than a guess. Also: skip the probe entirely on the create path, where there is no previous side and the skip could never fire; rename `localIndexNames` to `tableIndexNames`, since it feeds the cross-region teardown too; and document that the skip compares the template's two sides rather than live capacity, so an out-of-band console edit is drift's job, not the deploy path's. Tests: the cross-region index-teardown test was VACUOUS -- both its assertions were already satisfied by the local teardown loop and the pre-existing table-level cross-region call, so it passed with the entire cross-region index loop deleted. It now counts the index-read deregisters (2, one per region) and fails when that loop is removed. Added the missing fence for the TABLE-level 'seed' call site, which no fixture pinned: flipping it to 'min' previously broke nothing.
github-actions Bot
pushed a commit
that referenced
this pull request
Aug 9, 2026
## [0.278.20](v0.278.19...v0.278.20) (2026-08-09) ### Bug Fixes * **dynamodb:** register per-index auto-scaling and create GlobalTables at MinCapacity ([#1451](#1451)) ([62e378b](62e378b))
|
🎉 This PR is included in version 0.278.20 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes two
AWS::DynamoDB::GlobalTabledefects that had to ship together. #1435 says so explicitly: flipping the seed precedence alone makes things worse, because a table created atMinCapacitywith no registered scaling policy is pinned at min forever — strictly worse than today's over-provision, which was accidentally acting as headroom for the missing registration.#1419 — auto-scaling was registered for
dynamodb:table:*onlyThree gaps with one root:
dynamodb:index:*dimension was ever registered. A per-GSIwriteCapacity: Capacity.autoscaled(...)produced a correct INITIAL capacity and then droppedMinCapacity/MaxCapacity/TargetTrackingScalingPolicyConfiguration. The index sat at its initial capacity forever. This read as "working" precisely becauseAWS::DynamoDB::GlobalTable: GSIWrite/ReadProvisionedThroughputSettings/ on-demand settings not translated to SDK shape — PROVISIONED GSI create fails, TableV2 per-GSI limits silently dropped #1387's integ asserted that initial value and passed.create()calledapplyAutoScalingDiffzero times. Onlyupdate()ever did, so a freshly created PROVISIONED table had no scaling policy at all until some later deploy happened to run an update.update()'s replica loops allcontinueon the deploy region, so only CROSS-REGION replicas ever got a read target.Fix
applyAutoScalingDiffis generalized to the four DynamoDB scalable dimensions. Index targets register againsttable/<t>/index/<i>; the policy name keeps AWS's own<metricType>:<resourceId>convention, so table-level policy names stay byte-identical — a renamed policy would orphan the existing one on every already-deployed table.A pure
collectAutoScalingTargets(properties, localRegion)walks the four asymmetric CFn sources (both write dimensions live on the local region; read dimensions are per-replica, including the local one). Three sites reconcile from it:create()update()step 6bdelete()DescribeTable, not the possibly-stale templateA desired target is re-asserted even when the template did not change. A purely diff-gated register would never backfill the tables this issue is about: on anything deployed before this change the settings are byte-identical on both sides of every later deploy, so the dimension stays unregistered forever. Two refinements keep that affordable and safe:
2 x (1 + N_gsi x (1 + N_replica))serial calls -- over a hundred round trips on a 20-GSI, 3-replica table. One batchedDescribeScalableTargetsper region is issued first, and an already-present, unchanged target is skipped. A failed probe means presence is unknown and everything is upserted -- the correct direction to fail.RegisterScalableTarget/PutScalingPolicyalso carry a throttle-only retry: every error in this path is swallowed into a WARN, so an un-retriedThrottlingExceptionwould silently re-create the never-registered gap under exactly the burst this change introduces.The
delete()teardown is not optional bookkeeping — application-autoscaling is a separate control plane, so a target survivesDeleteTableand is silently inherited by a future table of the same name.#1435 — initial capacity came from
SeedCapacitywhere CloudFormation usesMinCapacityderiveRead/WriteCapacityUnitsended in a fixedSeedCapacity ?? MinCapacitychain. CloudFormation's precedence is context-dependent.Live-verified against a real CloudFormation stack (
CdkdIssue1427Control, us-east-1): aTableV2withMinCapacity: 1 / SeedCapacity: 20reachedCREATE_COMPLETEatWriteCapacityUnits: 1on both table and index, withNumberOfDecreasesToday: 0ruling out a scale-down between create and read-back. AWS documentsSeedCapacityonly for the billing-mode transition, and the registry schema marksMin/MaxCapacityRequired: YesagainstSeedCapacity'sRequired: No.The helpers now take a
CapacitySource—'seed'at the threePAY_PER_REQUEST -> PROVISIONEDflip call sites and'min'everywhere else. cdkd stops over-provisioning every autoscaled PROVISIONED GlobalTable by the seed-to-min ratio. The symptom was a silent billing one, never an error.Review
3-axis review (1125 LOC, provider-path up-bias). One blocker and eight lesser findings, all addressed in
11b3aa4b:try, so a later wiring failure deleted the table and orphaned the targets it had just registeredGlobalSecondaryIndexes, which AWS may omit for an inheriting replicaCREATINGwhile the table reports ACTIVE'seed'call site unpinned (no fixture in the tree had a table-levelSeedCapacity)dynamodb:index:ReadCapacityUnitshad no real-AWS coverageNot changed, deliberately:
toSdkReplicaGlobalSecondaryIndexestakes noCapacitySource. It is not reached on the billing-flip path, so the seed context cannot apply to it today.Tests
20 unit tests in a dedicated file, plus reworked capacity-precedence tests (5 expectations moved 3 -> 2 to match the CloudFormation semantics above).
The three
update()tests are mutation-proofed: disabling step 6b fails exactly those three and nothing else.The roundtrip suite's "no-op when the settings are identical on both sides" test encoded the OLD contract and is replaced by two tests pinning the new one: no-op when the target is already registered, backfill when it is not.
Integ
tests/integration/dynamodb-globaltable/verify.shgains three steps:MinCapacityassertion, all against the BASELINE deploy. That placement is the point: no update deploy has run yet, so it pins the create-side half of the fix.Fixture changes that make those assertions discriminating: the GSI's read capacity becomes autoscaled (
minCapacity: 7, so step 4b's existingReadCapacityUnits = 7assertion is unchanged) givingdynamodb:index:ReadCapacityUnitsreal-AWS coverage, and the table-level write gains aseedCapacity(8) differing from itsminCapacity(1) so #1435 is provable at table level and not only per-index. Step 4b's write expectation moved 3 -> 2.Docs
New
docs/changelog-cdkd.mdentry, and the #1387 entry's now-stale present-tenseSeedCapacity-first claim is marked superseded.Verification
Real-AWS
dynamodb-globaltableinteg,us-east-1, ~10 min: PASS. Baseline deploy + 4 update phases + destroy, 3 tables deleted, 0 errors / 0 orphans. Post-run sweep confirmed empty across all four surfaces: state (only thedeployments/event store, which legitimately survives), DynamoDB tables, application-autoscaling scalable targets, and scaling policies.Step 16a2 confirmed all four dimensions deregistered by destroy — the leak this PR closes:
Local: typecheck / lint / build / 527 files / 9083 tests green; all 5 codegen critics clean; no generated-artifact drift.
Follow-ups filed while doing this work
/work-issuesclaim protocol lost a 20-second race between two sessions on these very issues (fix in chore(skills): make the work-issues claim protocol race-safe #1447).provider-registry.test.tsfails order-dependently in the full suite (passes in isolation and on re-run). Pre-existing, unrelated to this diff, but it can mask real failures.<cmd> && gh pr create|mergechain (only a leadingcdis matched) #1455 — the PR gate hooks are bypassed by any<cmd> && gh pr create|mergechain; only a leadingcdis matched. Found because this PR's owngh pr createslipped past theverify-prgate.Closes #1419
Closes #1435