fix(graph): refuse change-tier without target capacity; make the drain fail loudly - #1305
Conversation
…n fail loudly change-tier could move a customer's Stripe price and then start a migration whose worker raised the target ASG's desired capacity itself and waited five minutes for a cold instance to claim the volume. It now applies the same refuse-the-sale rule as checkout — a healthy writer on the target tier with a free slot, or 409 before anything moves — through one shared capacity helper in the ops layer. The drain step no longer times out into a detach. An unreachable graph API counts as drained (the maintenance-window procedure stops the container first, and every write goes through that API); a graph API without a drain endpoint, an error response, or a timeout with connections still open now raise DrainRefusedError before the volume is touched.
|
@claude please review this PR |
|
Claude finished @jfrench9's task in 2m 39s —— View job Reviewing PR #1305
|
…efore trusting it Review follow-ups on #1305. A source instance with no private IP in the registry can no longer be taken as drained — nothing could be asked, so nothing is detached. And "unreachable" is now confirmed across three attempts before it is read as a stopped container, so a network blip during the drain does not pass for the maintenance window.
|
Follow-up in ace5b80: (1) no private IP now raises |

Summary
change-tieris the designated upsell route from Standard to Large/XLarge, and those tiers are provisioned on request (their writer ASGs sit at desired 0 by policy). The operation did not check that first: it moved the customer's Stripe price, set the subscriptionupgrading, and enqueued a migration whose worker raised the target ASG's desired capacity itself and then waited at most 300 s for a cold instance to boot, register, and claim the volume — a window a cold boot does not reliably fit, ending in a rollback that has only ever run in a test. Separately, the migration's drain step polled admin routes the graph API does not serve, timed out after 120 s, and detached the volume anyway.This PR makes
change-tierapply the same refuse-the-sale rule checkout already applies — a healthy writer on the target tier with a free slot, or a 409 before anything moves — and makes the drain refuse rather than time out into a detach.Changes
operations/graph/capacity.py(new) —tier_capacity_status(tier):readywhen the tier has a healthy writer with a free slot;scalableand any lookup failure read asat_capacity(fail closed). Lifted verbatim from the checkout router so both sale paths share one definition.routers/billing/checkout.py— imports the shared helper under its existing private name; behaviour unchanged.operations/graph/commands/tier.py— after tier validation and before any write:tier_capacity_status(new_tier) != "ready"→409"No capacity is currently available on the '' tier. Request access from the tier picker or contact support." Nothing is committed, Stripe is not called, no task is enqueued.operations/graph/tasks/graph_tier_upgrade.py—_drain_instancenow has three outcomes instead of one:404on/admin/drain, or any error status →DrainRefusedErrorbefore the detach;DrainRefusedErrorbefore the detach.The existing
except Exceptioninrun()catches it, rolls back the registry state (nothing has been detached), and re-raises. Module docstring updated to say so. Reviewers: the "unreachable counts as drained" branch is the judgment call — it is what lets the documented stop-the-container procedure work without a real drain endpoint.tests/operations/graph/test_capacity.py(new, 5);test_tier_command.pygains an autouse "capacity ready" fixture and a 409 test asserting no commit / no Stripe / no enqueue;test_graph_tier_upgrade.pygains three drain tests (unreachable, 404, timeout) and gives the existing happy-path stub a real status code.Breaking Changes
None. The only observable change is a new
409fromPOST /v1/graphs/{g}/operations/change-tierwhen the target tier has no capacity; the operation envelope and request model are unchanged, so nothing in either SDK tier moves.Testing
just test— 14,185 passed, 42 skipped.just test-code— clean (ruff, format, basedpyright, cfn-lint); also re-run by the pre-commit hook on both commits.Certification