Skip to content

Commit 11b3aa4

Browse files
committed
fix(dynamodb): address 3-axis review of the GlobalTable auto-scaling fix
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.
1 parent 3d42ff0 commit 11b3aa4

7 files changed

Lines changed: 619 additions & 135 deletions

File tree

docs/changelog-cdkd.md

Lines changed: 3 additions & 1 deletion
Large diffs are not rendered by default.

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

Lines changed: 337 additions & 99 deletions
Large diffs are not rendered by default.

tests/integration/dynamodb-globaltable/lib/dynamodb-globaltable-stack.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,16 @@ export class DynamoDBGlobalTableStack extends cdk.Stack {
165165
readCapacity: ddb.Capacity.fixed(5),
166166
// TableV2 requires auto-scaled write capacity (the GlobalTable CFn
167167
// shape has no literal WriteCapacityUnits).
168+
//
169+
// `seedCapacity` (8) differs from `minCapacity` (1) so the TABLE-level
170+
// half of issue #1435 is discriminating against real AWS: verify.sh
171+
// asserts the created table-level WriteCapacityUnits is 1. Without a
172+
// seed here, flipping the table-level call site back to 'seed' would
173+
// break no assertion and only the per-index value would be pinned.
168174
writeCapacity: ddb.Capacity.autoscaled({
169175
minCapacity: 1,
170176
maxCapacity: 10,
177+
seedCapacity: 8,
171178
targetUtilizationPercent: 70,
172179
}),
173180
}),
@@ -176,7 +183,18 @@ export class DynamoDBGlobalTableStack extends cdk.Stack {
176183
indexName: 'byStatus',
177184
partitionKey: { name: 'status', type: ddb.AttributeType.STRING },
178185
// -> Replicas[local].GlobalSecondaryIndexes[].ReadProvisionedThroughputSettings
179-
readCapacity: ddb.Capacity.fixed(7),
186+
//
187+
// AUTOSCALED rather than fixed so the fourth scalable dimension,
188+
// `dynamodb:index:ReadCapacityUnits`, gets real-AWS coverage — with
189+
// a fixed read capacity that dimension is never registered and half
190+
// the new index-level surface would be unit-tested only. minCapacity
191+
// is 7 so step 4b's existing `ReadCapacityUnits = 7` assertion holds
192+
// unchanged (issue #1435: a create takes MinCapacity).
193+
readCapacity: ddb.Capacity.autoscaled({
194+
minCapacity: 7,
195+
maxCapacity: 70,
196+
targetUtilizationPercent: 65,
197+
}),
180198
// -> GlobalSecondaryIndexes[].WriteProvisionedThroughputSettings
181199
// .WriteCapacityAutoScalingSettings.
182200
//

tests/integration/dynamodb-globaltable/verify.sh

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,32 @@ case "${INDEX_POLICY_TARGET}" in
286286
;;
287287
esac
288288

289+
# The READ half of the same index. Registered from a DIFFERENT CFn location
290+
# than the write half (`Replicas[local].GlobalSecondaryIndexes[]` rather than
291+
# the top-level GSI), so it is a genuinely separate path, not a mirror.
292+
INDEX_READ_MINMAX="$(aws application-autoscaling describe-scalable-targets \
293+
--service-namespace dynamodb \
294+
--resource-ids "${INDEX_RESOURCE_ID}" \
295+
--scalable-dimension dynamodb:index:ReadCapacityUnits \
296+
--region "${REGION}" \
297+
--query 'ScalableTargets[0].[MinCapacity,MaxCapacity]' --output text)"
298+
if [ "${INDEX_READ_MINMAX}" != "7 70" ]; then
299+
echo "[verify] FAIL (issue #1419): no dynamodb:index:ReadCapacityUnits target on ${INDEX_RESOURCE_ID}" >&2
300+
echo "[verify] got Min/Max '${INDEX_READ_MINMAX}', expected tab-separated '7' and '70'" >&2
301+
exit 1
302+
fi
303+
echo "[verify] step 4c ok: index read autoscaling registered (${INDEX_READ_MINMAX})"
304+
305+
# Issue #1435 at TABLE level: the fixture declares minCapacity 1 / seedCapacity 8,
306+
# and CloudFormation creates at MinCapacity. Pre-fix cdkd sent the seed (8).
307+
PROV_TABLE_WRITE="$(aws dynamodb describe-table --table-name "${GSI_PROV_TABLE}" --region "${REGION}" \
308+
--query 'Table.ProvisionedThroughput.WriteCapacityUnits' --output text)"
309+
if [ "${PROV_TABLE_WRITE}" != "1" ]; then
310+
echo "[verify] FAIL (issue #1435): ${GSI_PROV_TABLE} table-level WriteCapacityUnits is '${PROV_TABLE_WRITE}' (expected 1 = MinCapacity, not 8 = SeedCapacity)" >&2
311+
exit 1
312+
fi
313+
echo "[verify] step 4c ok: table-level write capacity created at MinCapacity (${PROV_TABLE_WRITE})"
314+
289315
echo "[verify] step 5 (was steps 5/6/7): cdkd deploy with CDKD_TEST_UPDATE=deletion-protection (in-place update — Issue #389)"
290316
# ORDER NOTE (PR follow-up to #403): TTL toggle is intentionally
291317
# deferred to the END of the integ flow. AWS's DynamoDB
@@ -587,17 +613,27 @@ echo "[verify] step 16a2 (Issue #1419): assert the per-INDEX scalable target did
587613
# remove a registered target. An orphan `table/<t>/index/<i>` target is
588614
# silently inherited by a future table of the same name, so delete() has to
589615
# deregister the index dimensions the way it already did the table ones.
590-
INDEX_TARGETS_AFTER="$(aws application-autoscaling describe-scalable-targets \
591-
--service-namespace dynamodb \
592-
--resource-ids "table/${GSI_PROV_TABLE}/index/byStatus" \
593-
--scalable-dimension dynamodb:index:WriteCapacityUnits \
594-
--region "${REGION}" \
595-
--query 'length(ScalableTargets)' --output text)"
596-
if [ "${INDEX_TARGETS_AFTER}" != "0" ]; then
597-
echo "[verify] FAIL (issue #1419): index scalable target survived destroy (count=${INDEX_TARGETS_AFTER}, expected 0)" >&2
598-
exit 1
599-
fi
600-
echo "[verify] step 16a2 ok: index scalable target deregistered on destroy"
616+
assert_target_gone() { # $1 = resource id, $2 = scalable dimension
617+
local remaining
618+
remaining="$(aws application-autoscaling describe-scalable-targets \
619+
--service-namespace dynamodb \
620+
--resource-ids "$1" \
621+
--scalable-dimension "$2" \
622+
--region "${REGION}" \
623+
--query 'length(ScalableTargets)' --output text)" || return 1
624+
if [ "${remaining}" != "0" ]; then
625+
echo "[verify] FAIL (issue #1419): scalable target $1 ($2) survived destroy (count=${remaining}, expected 0)" >&2
626+
exit 1
627+
fi
628+
echo "[verify] step 16a2 ok: $2 on $1 deregistered"
629+
}
630+
# BOTH index dimensions, and the local table read dimension this change is
631+
# what first registers. A teardown that covers only the write half leaks the
632+
# other three onto whatever table next takes this name.
633+
assert_target_gone "table/${GSI_PROV_TABLE}/index/byStatus" dynamodb:index:WriteCapacityUnits
634+
assert_target_gone "table/${GSI_PROV_TABLE}/index/byStatus" dynamodb:index:ReadCapacityUnits
635+
assert_target_gone "table/${TABLE_NAME}" dynamodb:table:ReadCapacityUnits
636+
assert_target_gone "table/${TABLE_NAME}" dynamodb:table:WriteCapacityUnits
601637

602638
echo "[verify] step 16b: assert cdkd state is empty"
603639
assert_gone "cdkd state file still exists at s3://${STATE_BUCKET}/${STATE_KEY}" aws s3api head-object --bucket "${STATE_BUCKET}" --key "${STATE_KEY}"

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ vi.mock('../../../src/utils/logger.js', () => {
7676

7777
import {
7878
DynamoDBGlobalTableProvider,
79+
derivePerCallProvisionedThroughput,
7980
deriveReadCapacityUnits,
8081
deriveWriteCapacityUnits,
8182
toSdkGlobalSecondaryIndexes,
@@ -260,6 +261,34 @@ describe('DynamoDBGlobalTable GSI throughput translation (issue #1387)', () => {
260261
).toBe(2);
261262
});
262263

264+
it('applies the source to the TABLE-level write block, not just per-GSI', () => {
265+
// The table-level flip call site passes 'seed' too. Without this, every
266+
// fixture in the tree carries SeedCapacity only on a GSI, so flipping
267+
// that call site to 'min' would break nothing and the seed context
268+
// would be pinned per-index only.
269+
const props = {
270+
WriteProvisionedThroughputSettings: {
271+
WriteCapacityAutoScalingSettings: { MinCapacity: 4, MaxCapacity: 40, SeedCapacity: 31 },
272+
},
273+
Replicas: [
274+
{
275+
Region: 'us-east-1',
276+
ReadProvisionedThroughputSettings: {
277+
ReadCapacityAutoScalingSettings: { MinCapacity: 6, MaxCapacity: 60, SeedCapacity: 22 },
278+
},
279+
},
280+
],
281+
};
282+
expect(derivePerCallProvisionedThroughput(props, 'us-east-1')).toEqual({
283+
ReadCapacityUnits: 6,
284+
WriteCapacityUnits: 4,
285+
});
286+
expect(derivePerCallProvisionedThroughput(props, 'us-east-1', 'seed')).toEqual({
287+
ReadCapacityUnits: 22,
288+
WriteCapacityUnits: 31,
289+
});
290+
});
291+
263292
it('takes MinCapacity over SeedCapacity on the read side too', () => {
264293
expect(
265294
deriveReadCapacityUnits({

tests/unit/provisioning/dynamodb-globaltable-provider-index-autoscaling.test.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, it, expect, vi, beforeEach } from 'vite-plus/test';
2-
import { DescribeTableCommand, ResourceNotFoundException } from '@aws-sdk/client-dynamodb';
2+
import {
3+
DescribeTableCommand,
4+
DeleteTableCommand,
5+
UpdateTimeToLiveCommand,
6+
ResourceNotFoundException,
7+
} from '@aws-sdk/client-dynamodb';
38
import {
49
RegisterScalableTargetCommand,
510
PutScalingPolicyCommand,
@@ -327,6 +332,64 @@ describe('DynamoDBGlobalTable per-index auto-scaling (issue #1419)', () => {
327332
});
328333
});
329334

335+
it('registers LAST, so a failed wiring step can never orphan a target it created', async () => {
336+
// The partial-create cleanup deletes the table directly (it does not
337+
// route through delete(), so it deregisters nothing). A target
338+
// registered before a later wiring step failed would therefore be
339+
// orphaned in the application-autoscaling control plane with no table
340+
// left to name it. Registration is deliberately the last wiring step;
341+
// this pins it by failing the step that used to run after it (TTL).
342+
mockSend.mockImplementation((command: unknown) => {
343+
if (command instanceof UpdateTimeToLiveCommand) {
344+
return Promise.reject(new Error('ttl boom'));
345+
}
346+
if (command instanceof DescribeTableCommand) {
347+
return Promise.resolve({
348+
Table: {
349+
TableName: TABLE_NAME,
350+
TableArn: TABLE_ARN,
351+
TableStatus: 'ACTIVE',
352+
GlobalSecondaryIndexes: [{ IndexName: 'gsi1', IndexStatus: 'ACTIVE' }],
353+
Replicas: [{ RegionName: 'us-east-1', ReplicaStatus: 'ACTIVE' }],
354+
},
355+
});
356+
}
357+
return Promise.resolve({});
358+
});
359+
360+
await expect(
361+
provider.create('Prov', RESOURCE_TYPE, {
362+
...AUTOSCALED_PROPS,
363+
TimeToLiveSpecification: { AttributeName: 'expiresAt', Enabled: true },
364+
})
365+
).rejects.toThrow(/ttl boom/);
366+
367+
// Nothing was registered, so the cleanup's DeleteTable leaves no orphan.
368+
expect(registerInputs()).toEqual([]);
369+
});
370+
371+
it('does not fail the deploy when auto-scaling registration itself throws', async () => {
372+
// Best-effort contract: a table that AWS created successfully must not
373+
// be destroyed by the partial-create cleanup over a scaling-target
374+
// problem. `applyAutoScalingDiff` swallows send errors, so this pins
375+
// the surrounding guard by making the client factory itself reject.
376+
const boom = new Error('region resolution failed');
377+
const originalRegion = mockSend.getMockImplementation();
378+
void originalRegion;
379+
const provider2 = new DynamoDBGlobalTableProvider();
380+
(
381+
provider2 as unknown as { getLocalAutoScalingClient: () => Promise<never> }
382+
).getLocalAutoScalingClient = () => Promise.reject(boom);
383+
384+
const result = await provider2.create('Prov', RESOURCE_TYPE, AUTOSCALED_PROPS);
385+
386+
expect(result.physicalId).toBe(TABLE_NAME);
387+
expect(
388+
mockSend.mock.calls.map((c) => c[0]).some((c) => c instanceof DeleteTableCommand)
389+
).toBe(false);
390+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Auto-scaling registration failed'));
391+
});
392+
330393
it('registers nothing on a PAY_PER_REQUEST table (AWS rejects targets there)', async () => {
331394
await provider.create('Prov', RESOURCE_TYPE, {
332395
...AUTOSCALED_PROPS,
@@ -481,6 +544,59 @@ describe('DynamoDBGlobalTable per-index auto-scaling (issue #1419)', () => {
481544
expect(gone).toContainEqual([`table/${TABLE_NAME}`, 'dynamodb:table:ReadCapacityUnits']);
482545
});
483546

547+
it("deregisters a cross-region replica's index targets using the TABLE's index list", async () => {
548+
// A replica that inherits throughput can come back with its own
549+
// `GlobalSecondaryIndexes` list OMITTED (the per-replica entry is
550+
// "replica-specific settings", and its ProvisionedThroughputOverride is
551+
// documented as "if not described, uses the source table's"). Reading
552+
// index names off the replica would then leak every
553+
// dynamodb:index:ReadCapacityUnits target in that region past
554+
// DeleteTable. Index names are identical across replicas, so the
555+
// table-level list is the correct source.
556+
describeOnceThenGone({
557+
TableName: TABLE_NAME,
558+
TableArn: TABLE_ARN,
559+
TableStatus: 'ACTIVE',
560+
GlobalSecondaryIndexes: [{ IndexName: 'gsi1' }],
561+
Replicas: [
562+
{ RegionName: 'us-east-1' },
563+
// No GlobalSecondaryIndexes key at all — the inheriting shape.
564+
{ RegionName: 'eu-west-1' },
565+
],
566+
});
567+
568+
await provider.delete('Prov', TABLE_NAME, RESOURCE_TYPE, AUTOSCALED_PROPS);
569+
570+
expect(deregistered()).toContainEqual([
571+
`table/${TABLE_NAME}/index/gsi1`,
572+
'dynamodb:index:ReadCapacityUnits',
573+
]);
574+
expect(autoScalingRegionSpy).toHaveBeenCalledWith('eu-west-1');
575+
});
576+
577+
it('stays silent when a target was never registered (ObjectNotFoundException suppression)', async () => {
578+
// delete() tears down index dimensions for every table that has GSIs,
579+
// including tables that never had auto-scaling at all. AWS answers
580+
// ObjectNotFoundException for those, which must be suppressed — an
581+
// unsuppressed one would print two WARN lines per index on every
582+
// destroy of an ordinary table.
583+
describeOnceThenGone({
584+
TableName: TABLE_NAME,
585+
TableArn: TABLE_ARN,
586+
TableStatus: 'ACTIVE',
587+
GlobalSecondaryIndexes: [{ IndexName: 'gsi1' }],
588+
Replicas: [{ RegionName: 'us-east-1' }],
589+
});
590+
const notFound = new Error('No scaling policy found for service namespace: dynamodb');
591+
notFound.name = 'ObjectNotFoundException';
592+
mockAutoScalingSend.mockReset();
593+
mockAutoScalingSend.mockRejectedValue(notFound);
594+
595+
await provider.delete('Prov', TABLE_NAME, RESOURCE_TYPE, AUTOSCALED_PROPS);
596+
597+
expect(warnSpy).not.toHaveBeenCalled();
598+
});
599+
484600
it('takes index names from the live DescribeTable, not from the (possibly stale) template', async () => {
485601
describeOnceThenGone({
486602
TableName: TABLE_NAME,

0 commit comments

Comments
 (0)