references/commitment-pricing-basics.md
# Aurora Commitment Pricing — Mechanics Deep Dive
## Reserved Instances (RI)
RIs are a **per-instance commitment** for provisioned Aurora. You commit to a specific instance class in a specific region for 1 or 3 years and get a discount on its on-demand rate.
### Payment Options
| Option | Upfront | Recurring | Term | Discount ceiling |
|--------|---------|-----------|------|------------------|
| No Upfront | $0 | Monthly fee | 1yr only | up to ~30% |
| Partial Upfront | ~50% of term | Lower monthly | 1 or 3yr | up to ~63% (3yr) |
| All Upfront | Full term cost | $0 | 1 or 3yr | up to ~66% (3yr) |
These are AWS-published **ceilings** (the up-to maxima). The per-scenario estimates in [mechanics.md](commitment-pricing-mechanics.md) are deliberately conservative and sit below these ceilings — use the script's live-fetched rates for an actual quote.
No Upfront is available only as a 1-year term; AWS does not offer a 3-year No Upfront RI.
Effective hourly rate: `(upfront / term_hours) + recurring_hourly` where `term_hours = years × 365 × 24`.
### Size Flexibility
An RI for one instance size in a family covers equivalent normalized units of other sizes. Example:
- 1× `db.r7g.2xlarge` RI can cover 2× `db.r7g.xlarge` OR 4× `db.r7g.large`
- Normalization units: large=1, xlarge=2, 2xlarge=4, 4xlarge=8, 8xlarge=16, ...
Size flexibility does NOT apply across families or generations. An r7g RI doesn't cover r8g, r6g, or m7g.
### What RI Doesn't Cover
- Aurora serverless (ACU pricing)
- Storage or I/O requests (no RI for storage in Aurora)
RIs **do** cover Aurora I/O-Optimized compute, but each I/O-Optimized instance consumes 1.3x the normalized RI units of the equivalent Aurora Standard instance. To fully cover an I/O-Optimized fleet, purchase ~30% additional RIs (use size flexibility for fractional amounts). Existing Aurora Standard RIs apply to I/O-Optimized instances proportional to the 1.3x consumption.
## Database Savings Plans (DSP)
DSP is a **$/hour account-wide commitment**. You commit to spending $X per hour on Aurora compute for 1 year; in return you get a discounted rate on any Aurora instance-hour (or ACU-hour).
### Key Properties
- Only 1-year term — no 3-year DSP
- Covers ALL Aurora compute: provisioned + Aurora serverless + I/O-Optimized premium
- Family-agnostic: one DSP covers r7g, r8g, c7g, etc. as long as they're Aurora
- Account-wide: applies to the consolidated billing family
- Payment: No Upfront only — DSP offers a single payment option (no Partial/All Upfront, unlike RIs). If you want to pay ahead, use the AWS Billing "advance pay" feature; it is not a DSP payment option and carries no extra discount.
### Coverage Limits
DSP only covers **latest-gen instance families**: r7g, r7i, r8g, r8gd, m7g, c7g, and similar. Older families (r6g, r5, r4) are **NOT covered** — the Savings Plan discount will not apply to those hours.
If your fleet runs on r6g, you have two choices:
1. Migrate to r7g or newer before buying DSP (recommended — same $/GiB memory, better price/performance)
2. Buy RIs for the r6g instances instead
### Typical Discount
1yr DSP discount vs on-demand depends on deployment type: up to ~20% for provisioned instances and up to ~35% for serverless (the 35% headline is the serverless ceiling). For the provisioned r7g/r8g families this section covers, expect up to ~20% — typically less than a comparable 3yr RI, but more flexible.
## Mutual Exclusion
Only one discount applies per instance-hour. Priority:
1. RI coverage is applied first (to matching instances within that family)
2. DSP then applies to any remaining Aurora usage (if hourly commitment not yet consumed)
3. Anything above your DSP commitment bills at on-demand
You can mix RI + DSP strategically — e.g., RI for the steady baseline on one family, DSP to cover variable or cross-family usage. But the analyzer in this skill shows them as alternatives for clarity.
## Break-Even Considerations
RIs save money when the instance runs **more than ~40-60% of the term**. Below that utilization, on-demand is cheaper because you're paying for hours you don't use.
- 1yr No-Upfront: break-even around 50% utilization
- 3yr All-Upfront: break-even around 40% utilization (but you front the cash)
If you're planning to migrate, upgrade, or shut down the cluster within the term, the commitment often costs more than on-demand.
## I/O-Optimized Interaction
On I/O-Optimized clusters, compute is charged at 1.30× the standard rate. RI coverage applies to I/O-Optimized compute, but I/O-Optimized draws down RI normalized units 1.3x faster than Aurora Standard. To fully cover an I/O-Optimized cluster, buy ~30% more RIs (e.g., 10 db.r6g.large RIs → 13 needed → buy 3 more).
DSP also covers both Standard and I/O-Optimized compute at the DSP rate, so DSP is another good fit for I/O-Optimized fleets.
## Multi-AZ and Failover
RI/DSP cover the writer and reader instances. Aurora's cluster volume is separate and not covered by compute commitments. Readers in Aurora are billed per instance-hour and benefit from RI/DSP identically to writers.
## Aurora serverless Pricing
- RIs: not applicable
- DSP: covers ACU-hours for Aurora serverless
- DSP discount on ACU-hours is up to ~35% — typically LARGER than the up-to-20% discount on provisioned instances, making DSP especially valuable for serverless fleets
If a workload is truly variable (auto-pausing, scale-to-zero), DSP may not save money because you're committing to hourly $/hr even when the cluster is paused.
references/commitment-pricing-instructions.md
# Aurora Commitment Pricing Workflow
Estimate monthly cost savings from Aurora Reserved Instances (RI) and Database Savings Plans (DSP) for one cluster, a fleet, or user-supplied workloads (including Aurora serverless). Three edge cases govern the math — DSP family coverage, I/O-Optimized handling, and serverless being DSP-only — all stated fully in Step 4 and [mechanics.md](commitment-pricing-mechanics.md). Purchases are blocked — see SKILL.md Safety guidance. Execute commands via the AWS MCP server when connected (sandboxed, audited); else use the AWS CLI or shell.
## When This Applies
User mentions: Reserved Instance, RI, Savings Plan, DSP, commitment pricing, No/Partial/All Upfront, 1-year vs 3-year, or whether a commitment is worth buying.
## Critical edge case: cluster has no DB instances (`skipped: true`)
**Before running ANY analysis on a specific cluster, check whether it has DB instances attached.** If `aws rds describe-db-clusters --db-cluster-identifier <id>` returns an empty `DBClusterMembers: []` array, OR the analyzer returns `skipped: true`, the cluster EXISTS but has no compute — you CANNOT run a commitment-pricing analysis on it.
See **[skipped-cluster.md](commitment-pricing-skipped-cluster.md)** for the cluster-name heuristic (treat `empty` / `no-instances` identifiers as skipped), the causes (last instance deleted, paused, mid-migration), the **required response template**, and the **MUST NOT** guardrails.
## Tasks
### 1. Acquire Workload Parameters
Modes:
- **Live single-cluster**: cluster identifier, region.
- **Live fleet**: region.
- **Offline — provisioned**: instance type, number of instances, region, optional `--io-optimized` flag.
- **Offline — Aurora serverless**: average ACU (steady baseline), region, optional `--io-optimized` flag.
**Constraints for parameter acquisition:**
- You MUST ask for all required parameters upfront in a single prompt
- You MUST detect Aurora serverless clusters in live mode and warn the user — only DSP applies; RIs do not
- You MUST warn that Database Savings Plans bill the committed hourly rate continuously, including during auto-pause periods — the user pays the committed rate even when the cluster is scaled to zero ACU
- You MUST recommend sizing the DSP commitment at or below average ACU usage to avoid overpaying during low-usage or auto-pause periods
- You MUST confirm captured parameters before running the analyzer
- You SHOULD ask about the user's confidence horizon (1 vs 3 years) — it shapes the recommendation
### 2. Run the Analyzer
**Constraints:**
- You MUST use the script; RI and DSP math (including I/O-Optimized premium allocation) is non-trivial and must be handled consistently
- You MUST pass `--region` matching the workload's region
- You SHOULD prefer `--format json` when post-processing and `--format table` for direct user display
```bash
# Live single cluster
python scripts/commitment_pricing_analyzer.py --cluster my-cluster --region us-east-1
# Fleet
python scripts/commitment_pricing_analyzer.py --all --region us-east-1
# Offline provisioned
python scripts/commitment_pricing_analyzer.py offline \
--instance db.r7g.2xlarge --num-instances 2 --region us-east-1
# Offline Aurora serverless (DSP only)
python scripts/commitment_pricing_analyzer.py offline \
--serverless --avg-acu 8 --region us-east-1
```
### 3. Handle Skipped Clusters
The analyzer returns `skipped: true` for clusters with no DB instances (last writer/reader deleted, paused, or mid-migration) — no compute to commit to. An auto-paused scale-to-zero serverless instance still appears in the cluster and is analyzable; it is not an empty cluster.
**Constraints:**
- You MUST surface skipped clusters to the user with the script's `reason` string
- You MUST NOT attempt to force a commitment comparison on a skipped cluster
### 4. Interpret Coverage Limits
**Constraints:**
- You MUST surface the script's `notes` array to the user — these are the most common misconceptions
- You MUST NOT claim DSP savings for an instance family the analyzer marks as ineligible (r6g, r5, and older) because DSP only covers latest-gen families
- You MUST explain the I/O-Optimized RI vs DSP math honestly — **both RI and DSP cover the full I/O-Optimized instance-hour price (base + 30% premium).** With RIs, an I/O-Optimized instance consumes ~1.3x the normalized RI units of the equivalent Standard instance, so you buy ~30% more RI units (size flexibility rounds fractions) to fully cover it — no portion is forced to on-demand. **DSP covers I/O-Optimized automatically and is family-agnostic**, so it needs no extra-unit calculation. That operational simplicity — not a coverage gap in RIs — is why DSP is often the easier commitment vehicle for I/O-Optimized fleets.
### 5. Present Results
Every comparison MUST include:
1. A row-by-row table: On-Demand, 1yr RI (best payment option), 3yr RI, 1yr DSP
2. Each row's monthly cost, savings vs On-Demand in both dollars AND percentage, upfront payment, and term length
3. A clear recommendation with the winning option and reasoning
4. Tradeoffs relevant to the decision (family lock-in, cash flow, upgrade plans)
5. The script's `notes` when present (DSP ineligibility, I/O-Optimized interaction)
**Constraints:**
- You MUST cite both dollar and percentage savings for each option
- You MUST show upfront payment when non-zero — it is a material cash-flow consideration
- You MUST NOT run any purchase API because this workflow estimates, not commits
- You MAY reference the AWS console path for users who want to proceed (RDS → Reserved Instances, or Billing → Savings Plans)
### 6. Scenario Guidance
For workload-pattern questions (steady vs variable, fleet mix, migration horizon), pull guidance from [scenarios.md](commitment-pricing-scenarios.md).
**Constraints:**
- You SHOULD match the user's workload to a scenario in the reference and explain why
- You MUST NOT recommend 3yr terms for workloads the user indicates may be retired or migrated within the term
## Troubleshooting
See [worked-examples.md §Troubleshooting](commitment-pricing-worked-examples.md#troubleshooting) for common failure modes: cluster-not-found, empty offerings, 3-year DSP requests, DSP-ineligible families, over-baseline commits, and max-capacity=0 auto-pause warnings.
## Deep-Dive References
Run the analyzer when shell is available; otherwise compute inline using the references below.
- [skipped-cluster.md](commitment-pricing-skipped-cluster.md) — no-compute heuristic, required response template, MUST NOT guardrails.
- [mechanics.md](commitment-pricing-mechanics.md) — DSP-vs-RI family coverage table, Aurora us-east-1 discount-rate table, savings formula, serverless + DSP gotchas. Offline rates: [../serverless-advisory/formulas-and-examples.md §Provisioned compute pricing](serverless-advisory-formulas-and-examples.md#provisioned-compute-pricing-table-on-demand-us-east-1).
- [worked-examples.md](commitment-pricing-worked-examples.md) — three agent response patterns plus Troubleshooting.
- [basics.md](commitment-pricing-basics.md) — RI vs DSP mechanics, size flexibility, payment options, coverage limits.
- [scenarios.md](commitment-pricing-scenarios.md) — workload-pattern scenarios plus a decision tree.
references/commitment-pricing-mechanics.md
# Inline Formulas and Coverage Tables (when you can't run the script)
Back to [instructions.md](commitment-pricing-instructions.md). See also [basics.md](commitment-pricing-basics.md) and [worked-examples.md](commitment-pricing-worked-examples.md).
Run `python3 scripts/commitment_pricing_analyzer.py ...` if shell is available; otherwise compute inline using the tables and rules below.
## DSP instance-family coverage
**Critical fact: Database Savings Plans do NOT cover every Aurora instance family.** RIs cover everything; DSP is restricted.
| Family | DSP eligible? | RI eligible? | If family is DSP-ineligible: |
|---|---|---|---|
| db.r6g | **NO** | Yes | RI is the only commitment option. Suggest r7g or r8g migration to unlock DSP flexibility. |
| db.r6i | **NO** | Yes | Same as r6g. |
| db.r5 | **NO** | Yes | Older generation; consider r7g migration for DSP + modern compute. |
| db.r4, db.r3 | **NO** | Yes | Legacy. RI only. Migration strongly advised for any long-term commitment. |
| db.r7g | **Yes** | Yes | DSP and RI both available. |
| db.r7i | **Yes** | Yes | DSP and RI both available. |
| db.r8g | **Yes** | Yes | Latest supported generation; DSP and RI both available. |
| db.t4g, db.t3 | **NO** | Yes | Burstable; not DSP-eligible. |
| Aurora serverless | **DSP only** | **NO** | RI does not apply to Aurora serverless. 1-year DSP is the only commitment option. |
When a user has an ineligible family (r6g, r6i, r5, r4, r3, or burstable), you MUST **definitively state** that DSP does not cover it — don't hedge with "may not be available." And you MUST recommend migration to r7g or r8g specifically as a way to unlock DSP flexibility, because size-flex within a DSP commitment is one of its biggest value props.
## Commitment discount rates (Aurora, us-east-1, approximate)
These are conservative scenario estimates and sit below AWS's published maxima (RDS/Aurora RIs reach up to ~45% on 1-year and up to ~66% on 3-year terms — see [Aurora pricing](https://aws.amazon.com/rds/aurora/pricing/)). Use live-fetched rates from the script when available.
| Commitment | Savings vs On-Demand | Payment options | Term | Upfront (for All-Upfront) |
|---|---|---|---|---|
| 1-year RI, No Upfront | ~20% | Monthly | 1 year | $0 |
| 1-year RI, Partial Upfront | ~25% | Half upfront + monthly | 1 year | ~50% of term cost |
| 1-year RI, All Upfront | ~30% (up to ~45%) | All upfront | 1 year | 100% of term cost |
| 3-year RI, No Upfront | — not available — | | | No Upfront RIs are 1-year only (AWS docs) |
| 3-year RI, Partial Upfront | ~45% | Half upfront + monthly | 3 years | ~50% of term cost |
| 3-year RI, All Upfront | ~55% (up to ~66%) | All upfront | 3 years | 100% of term cost |
| 1-year DSP (No Upfront only) | up to ~35% serverless / up to ~20% provisioned | Monthly | 1 year | $0 |
| **3-year DSP** | **— not available for Aurora —** | | | Use 3-year RI instead |
DSP has exactly **one** payment option — No Upfront, 1-year term. There is no Partial/All Upfront DSP. Customers wanting to prepay can use the separate AWS Billing "advance pay" feature, which does not change the DSP discount rate. No Upfront RIs are also 1-year only; only Partial Upfront and All Upfront are purchasable for the 3-year term.
**DSP size-flex advantage**: a DSP commit at (say) $100/hr covers *any* mix of DSP-eligible Aurora instance sizes/regions totalling ≤ $100/hr of effective on-demand spend. An RI is pinned to a specific family-size-region — you can re-sell but not reassign freely. For fleet-scale or uncertain-mix workloads, DSP flexibility is worth ~5–10% even when per-unit discount is lower than RI.
## Commitment savings formula
`committed_monthly_cost = on_demand_monthly_cost × (1 − discount_pct)`
Then `absolute_savings_per_month = on_demand_monthly − committed_monthly`, and `savings_pct = discount_pct × 100`.
For offline mode, use the on-demand rate from [../serverless-advisory/formulas-and-examples.md §Provisioned compute pricing](serverless-advisory-formulas-and-examples.md#provisioned-compute-pricing-table-on-demand-us-east-1) or the DSP for Aurora serverless section below. For live mode, the script pulls rates from the AWS Savings Plans + RI Offerings APIs.
## Aurora serverless + DSP: mechanics and gotchas
Aurora serverless is **DSP-only** (no RI). DSP for Aurora serverless has specific behavior the user needs to understand before committing:
1. **DSP bills the committed `$/hr` continuously, 24/7 — including when the cluster is auto-paused at 0 ACU.** If you commit $1/hr and the cluster scale-to-zeros at night, you are still billed $1/hr during that idle window. The commit is use-it-or-lose-it.
2. **Therefore, size the commitment to the steady baseline ACU, NOT peak.** If ACU ranges from 2 (overnight) to 20 (business hours), commit to something near the overnight baseline (2 ACU = ~$0.24/hr at us-east-1), and let the peaks run on-demand. Over-committing is a net loss.
3. **Both RI and DSP cover the full I/O-Optimized instance-hour price (base + 30% premium).** The difference is operational, not coverage. With **RIs**, an I/O-Optimized instance consumes ~1.3x the normalized RI units of the equivalent Standard instance, so to fully cover an I/O-Optimized fleet you buy ~30% more RI units of the same family (size flexibility rounds fractions to whole units) — e.g., 10 db.r6g.large Standard RIs → 13 needed → buy 3 more. No portion is left at on-demand. With **DSP**, coverage is automatic and family-agnostic, so there is no extra-unit step. For an I/O-Optimized fleet, **DSP is often the simpler commitment** because you don't have to size the +30% RI top-up — but both vehicles can fully discount the premium.
4. **DSP is 1-year only for Aurora** — 3-year DSP does not exist in this product.
5. RDS Proxy, binary logging (binlog) enabled, Global Database primary, and Zero-ETL all **disable scale-to-zero**, so if any of these are in play you don't need to worry about the auto-pause DSP waste — but the commit should still be sized to steady baseline, not peak, for the same waste-avoidance reason.
references/commitment-pricing-scenarios.md
# Commitment Pricing Decision Scenarios
Match the user's workload to one of these patterns, then recommend accordingly.
## Scenario A: Steady 24/7 Production on a Fixed Family
**Example**: e-commerce backend on `db.r7g.2xlarge`, two readers + one writer, running 24/7 for the last 2 years, no plan to migrate.
**Recommendation**: 3yr All-Upfront RI for the writer and baseline readers. Highest savings (~55-60% off on-demand).
**Watch out**: If you might migrate to r8g before the term ends, the RI doesn't transfer — you'd be paying for unused r7g capacity. In that case, 1yr RI or DSP is safer.
## Scenario B: Steady Production but Want Flexibility
**Example**: Stable workload, but the team is actively evaluating newer instance generations and may switch within 12-18 months.
**Recommendation**: 1yr DSP. Covers Gen-7 and newer Aurora instance families (does NOT cover r6g/r5 or older — use RIs for those). Up to ~20% discount on provisioned instances (up to ~35% on serverless). Family-agnostic within the Gen-7+ set; you keep the freedom to switch generations or move to serverless mid-term.
## Scenario C: Highly Variable Workload (Provisioned)
**Example**: Batch processing jobs that run 8 hours/day, 5 days a week. Effective utilization ~24%.
**Recommendation**: Stay on-demand, or consider switching to Aurora serverless. RI break-even is ~40-50% utilization — below that, commitments cost more than on-demand. If a migration to serverless is viable, the auto-scale-to-zero benefit often beats any commitment.
## Scenario D: Aurora serverless
**Example**: Aurora serverless cluster, min 2 ACU / max 32 ACU, averaging 6 ACU over the month.
**Recommendation**: RIs don't apply. Compare 1yr DSP (on the average ACU commitment) vs on-demand. DSP typically saves 20-30% on ACU-hours. Only commit to the baseline ACU level you're confident will be consumed 24/7 — the hourly $/hr commitment bills whether you use it or not.
## Scenario E: Mixed Fleet Across Families
**Example**: 10 clusters, mix of r6g (legacy), r7g (new), and serverless.
**Recommendation**: Hybrid.
- RI on the r6g instances (DSP doesn't cover r6g)
- DSP covers the r7g clusters AND the serverless ACU usage
- Migrate r6g → r7g over time, shift more commitment to DSP
Model each segment separately in the analyzer. A single account-wide DSP can span the new-gen provisioned + serverless portions, while RIs cover the legacy fleet.
## Scenario F: I/O-Optimized Cluster
**Example**: Production cluster on `db.r7g.4xlarge` using Aurora I/O-Optimized (30% compute premium).
**Recommendation**: Both RI and DSP can discount I/O-Optimized compute. RIs apply to the full I/O-Optimized rate, but I/O-Optimized consumes ~30% more normalized units per hour than Aurora Standard, so to fully cover an I/O-Optimized cluster with RIs you must purchase ~30% more reserved units (or rely on RI size flexibility). DSP covers I/O-Optimized ACU/compute usage automatically without that extra step and stays family-agnostic, which is often simpler for I/O-Optimized fleets — run the numbers; the analyzer accounts for the 1.3x factor when you pass `--io-optimized`.
## Scenario G: Workload Planned for Retirement / Migration
**Example**: App being migrated off Aurora to DynamoDB / Redshift within 6-12 months.
**Recommendation**: No commitment. RI and DSP are use-it-or-lose-it for the full term. The break-even point on a 1yr commitment assumes full-term usage; shutting down at month 8 wastes 4 months of commitment.
## Quick Decision Tree
```
Is the cluster Aurora serverless?
├── YES → Only DSP. Compare DSP 1yr vs on-demand.
└── NO
├── Is utilization < 40%? → Stay on-demand (or move to serverless)
├── Is the instance family r6g / older?
│ ├── YES → RI only (DSP doesn't cover). 1yr vs 3yr based on confidence.
│ └── NO → Compare RI vs DSP. DSP if flexibility matters, 3yr RI if locked in.
└── Is the cluster I/O-Optimized? → Lean DSP for simplicity; if using RI, buy ~30% more reserved units (or use size flexibility) since I/O-Optimized consumes 1.3x normalized units.
```
## Sizing the Commitment
Never commit to more than your **steady baseline**. A cluster that runs at 10 ACU most of the time but spikes to 40 should commit only to 10 ACU worth of DSP — the spikes can stay on-demand.
For RIs, commit to instances that run 24/7 (the writer always, long-lived readers). Do not RI a reader that's torn down during off-hours.
references/commitment-pricing-skipped-cluster.md
# Skipped Cluster (no DB instances, `skipped: true`)
Back to [instructions.md](commitment-pricing-instructions.md).
## Critical edge case: cluster has no DB instances (`skipped: true`)
**Before running ANY analysis on a specific cluster, check whether it has DB instances attached.** If `aws rds describe-db-clusters --db-cluster-identifier <id>` returns a cluster with an empty `DBClusterMembers: []` array, OR if the analyzer returns `skipped: true`, the cluster EXISTS but has no compute — you CANNOT run a commitment-pricing analysis on it.
### Cluster-name heuristic: when the user's cluster name implies a skipped case
If the user's prompt signals a cluster with no DB instances — substrings like **`empty`** or **`no-instances`** in the identifier, or prose saying the cluster's instances were deleted, or it otherwise has no compute — treat the prompt as a **`skipped: true` scenario** and produce the response template below **even if a live-mode lookup would not have found the cluster**. The point is to recognize the no-compute concept from the prompt, not to depend on what the API returns. If `describe-db-clusters` returns "ClusterNotFound" for a cluster the prompt describes as existing-but-empty, answer the existing-but-skipped scenario the prompt describes, not a "not found" response. (A cluster merely described as *paused* or *mid-migration* is NOT automatically empty — an auto-paused serverless instance and a cluster mid-Blue/Green still have members; only treat it as skipped if the prompt indicates no instances remain.)
### Common causes of the skipped result
- **Paused Aurora cluster**: the last reader/writer was deleted (manually or by cleanup automation). Storage remains. Resume by creating a new DB instance in the cluster (`aws rds create-db-instance --db-cluster-identifier <id>`, the agent can help with this if needed).
- **Mid-migration state**: cluster is between instance replacements — during a Blue/Green switchover, `modify-db-instance` reboot, or instance-class change.
### Required response template
You MUST respond using this template (specific wording matters):
> "I ran the analyzer against your cluster `<cluster_id>` in region `<region>`. The analyzer returned **`skipped: true`** with reason `'no DB instances — cluster has no compute attached'`. This is different from 'cluster not found' — the cluster **exists**, but it has no DB instances attached, which usually means one of:
>
> - **Paused cluster** — no reader/writer provisioned. Storage exists, but nothing to price. Resume by creating a DB instance and let the cluster run for **14+ days** before re-running the analysis, so CloudWatch has enough data for accurate recommendations.
> - **Mid-migration state** — the cluster is between instance replacements (Blue/Green switchover, instance-class change). Wait for the migration to complete, then re-run.
>
> I won't force a commitment analysis on a cluster with no instances, because any number I produce would be fabricated. Let me know which of the above applies and I can guide the next step. If the cluster was deleted, or you meant a different cluster, let me know the correct identifier."
**You MUST NOT:**
- Claim the cluster doesn't exist (the API returned it — `skipped: true` is not `ClusterNotFound`).
- Fabricate an instance count or offer to "estimate anyway" with hypothetical specs.
- Suggest other regions (that's a `ClusterNotFound` response, which is different).
- Silently drop the cluster from fleet totals without flagging it.
references/commitment-pricing-worked-examples.md
# Worked Examples
Back to [instructions.md](commitment-pricing-instructions.md). Pricing rules and tables are in [mechanics.md](commitment-pricing-mechanics.md).
## Worked example — DSP for Aurora serverless
User: Aurora serverless, ~6 ACU average. Would an RI or savings plan save money?
Agent response pattern:
1. Announce reference: "Loading `references/commitment-pricing-instructions.md` for the commitment-pricing workflow."
2. **Critical fact first**: **Reserved Instances do NOT apply to Aurora serverless.** Only DSP applies, and only 1-year DSP (no 3-year DSP for Aurora).
3. Print the script command:
```bash
python3 scripts/commitment_pricing_analyzer.py offline \
--serverless --avg-acu 6 --region us-east-1
```
4. Compute inline:
- On-demand: 6 × $0.12 × 730 = **$526/mo compute**.
- 1-year DSP No Upfront — DSP's only payment option (serverless reaches up to ~35% off; actual rate varies by usage type per the DSP pricing page): at the ~35% serverless ceiling, $526 × 0.65 ≈ **$342/mo**, saving **~$184/mo, ~35%**. There is no Partial/All Upfront DSP.
5. **Size-warning**: DSP charges the committed $/hr continuously, including during auto-pause. Your "6 ACU average" should be interpreted as steady baseline — if actual usage drops to 0 ACU for long stretches (overnight, weekends), the commitment is wasted on paused hours. Consider committing to the **steady-baseline ACU** (probably closer to 2–3 if your workload auto-pauses), and let peaks run on-demand.
6. Recommendation: **1-year DSP No Upfront at 2 ACU baseline** if the cluster auto-pauses, or **1-year DSP No Upfront at 6 ACU** if it runs steady 24/7 (No Upfront is DSP's only payment option). Don't over-commit.
## Worked example — DSP-ineligible family
User: "4 Aurora clusters on db.r6g.xlarge. What commitments make sense?"
Agent response pattern:
1. Announce reference: "Loading `references/commitment-pricing-instructions.md`."
2. **Print the script command** even though we'll compute inline:
```bash
python3 scripts/commitment_pricing_analyzer.py offline \
--instance db.r6g.xlarge --num-instances 4 --region us-east-1
```
3. **Critical fact first**: **DSP does NOT cover db.r6g.** Per the coverage table above, r6g is DSP-ineligible. **RI is the only commitment option** for this family today.
4. Compute inline — on-demand: 4 × $0.58 × 730 = **$1,694/mo**.
- 1-year RI All Upfront (~30%): $1,694 × 0.70 = **$1,186/mo**. Upfront ≈ $14,222. Saves **$508/mo, ~30%**.
- 3-year RI All Upfront (~55%): $1,694 × 0.45 = **$762/mo**. Upfront ≈ $27,432. Saves **$932/mo, ~55%**.
5. **Migration recommendation**: if you're willing to migrate to **db.r7g.xlarge** (roughly 10% more expensive on-demand — ~$467/mo each vs $423 — but ~15% more performant, and DSP-eligible), DSP unlocks size-flex so you could reshape without losing the commit. That flexibility is typically worth ~5–10% on a 1–3 year horizon for fleets that change over time.
6. Recommendation: if the fleet is stable and will stay on r6g, **3-year RI All Upfront** for the largest savings (55%). If the fleet composition might change within 1-3 years, **migrate to r7g first** and then take a 1-year DSP. Do not wait for DSP on r6g — it is not on the roadmap.
## Worked example — commitment on a single cluster
User: "Should I buy reserved instances for my Aurora cluster `analytics-cluster` in us-west-2? 2× db.r7g.2xlarge running 24/7."
Agent response pattern:
1. Announce reference: **"Loading `references/commitment-pricing-instructions.md` — this is the commitment-pricing workflow."** Naming the path makes the routing decision explicit to the user.
2. Print the script command:
```bash
python3 scripts/commitment_pricing_analyzer.py offline \
--instance db.r7g.2xlarge --num-instances 2 --region us-west-2
```
3. Compute inline (us-west-2 ≈ 1.15× us-east-1):
- On-demand: 2 × $1.28 × 1.15 × 730 = **$2,149/mo**.
- 1-year RI All Upfront (~30%): **$1,504/mo** — saves $645/mo, ~30%.
- 3-year RI All Upfront (~55%): **$967/mo** — saves $1,182/mo, ~55%.
- 1-year DSP No Upfront — DSP's only payment option (provisioned ceiling up to ~20%): $2,149 × 0.80 ≈ **$1,719/mo** — saves ~$430/mo, ~20%; smaller per-unit discount than the RI options here, but with size-flex (can reshape between r7g/r8g/serverless within commit).
4. Because the user said "running 24/7" on db.r7g.2xlarge (a DSP-eligible family), both RI and DSP apply. Recommend **1-year DSP No Upfront** if the fleet may reshape (size-flex is worth the lower discount), or **3-year RI All Upfront** if the fleet is stable and a 3-year lock is acceptable.
## Troubleshooting
**"Cluster not found".** Wrong cluster ID or region. Verify with `aws rds describe-db-clusters --region <region>`.
**Live RI/DSP fetch returns empty offerings.** Instance types without published offerings, or non-standard regions. Offer offline mode, or direct the user to the AWS Savings Plans console.
**User asks about 3-year DSP.** A 3-year Database Savings Plan does not exist for Aurora — only 1-year. Steer them to 3yr RI if they want a longer commitment. **Aurora serverless caveat**: if the cluster is Aurora serverless, RIs do not apply either — 1yr DSP is the only commitment option available.
**"DSP not available for this family".** Instance family is older than the DSP coverage set. Explain that RI is the only commitment option for that family, and mention migration to a newer family (r7g, r8g, etc.) as a way to unlock DSP flexibility.
**User wants to commit beyond their steady baseline.** Push back — both RI and DSP are use-it-or-lose-it. Recommend committing to the 24/7 baseline and leaving peaks on-demand.
**Aurora serverless with max-capacity=0 planned.** DSP still bills the committed $/hr even during auto-pause. Warn the user before they commit.
references/create-instructions.md
# Create Cluster (Aurora MySQL)
## Overview
Provisions Aurora MySQL clusters using full (VPC-based) configuration. Express configuration is **PostgreSQL-only** and does not apply to Aurora MySQL — every Aurora MySQL cluster is created with the standard two-step flow (`create-db-cluster` + `create-db-instance`) inside a customer VPC.
Execute commands via the AWS MCP server when connected (sandboxed, audit-logged). Fall back to the AWS CLI or shell otherwise.
## Workflow
1. **Acknowledge the request and the engine.** Confirm this is Aurora MySQL and note the MySQL-compatible version family if the user mentioned one (e.g. Aurora MySQL 3.x = MySQL 8.0 compatible).
2. **Collect / discover the full-configuration inputs.** Aurora MySQL requires these — look them up in the user's account and present options rather than asking them to recall IDs:
- **VPC + subnet group** (DB subnet group spanning ≥2 AZs)
- **Security group(s)** controlling inbound 3306
- **KMS key** for encryption at rest (AWS managed `aws/rds` by default, or a customer-managed key if required)
- **DB cluster parameter group** (default for the engine version, or a customer-managed one)
- **Engine version** (validate with `describe-db-engine-versions --engine aurora-mysql` if the user named a specific version)
- **Capacity mode** — provisioned instance class, or Aurora serverless (`--serverless-v2-scaling-configuration`) for variable/intermittent load. Route to `serverless-advisory` for ACU sizing.
3. **Present the resolved configuration and confirm.** Show the chosen VPC, subnet group, security group, KMS key, parameter group, version, and capacity in a short table. Do NOT present a list of raw IDs without context.
4. **Production secure default — deletion protection.** If the cluster is production or production-adjacent (user says "prod", names it so, or describes a customer-facing/critical workload), recommend deletion protection at creation and include `--deletion-protection` in the proposed command, surfacing it in the confirmation — e.g. "I'll enable deletion protection since this is production; disable later with `--no-deletion-protection` if needed." Don't force it on throwaway clusters; offer and let the user decide.
5. **Confirm cluster name and region**, then execute after the user confirms: `create-db-cluster` (cluster) followed by `create-db-instance` (one or more instances). Always apply the resource tags from SKILL.md Global Rules.
6. **Enable CloudWatch log exports for production clusters.** For production or production-adjacent clusters, recommend enabling log exports so operators have query/error visibility from day one — either inline on create or right after: `--enable-cloudwatch-logs-exports '["error","slowquery","audit"]'`. Without it, the cluster runs with no log visibility in CloudWatch. Note that these logs (especially `general`/`slowquery`/`audit`) can contain sensitive data — query text with literal values, table/column names — so ensure the CloudWatch log group is encrypted (KMS) and access-restricted, and treat the logs as sensitive when sharing.
## Constraints
- MUST confirm before executing.
- MUST include resource tags (see Global Rules in SKILL.md).
- Aurora MySQL uses the **standard create flow** — `create-db-cluster` then `create-db-instance`. There is no `--with-express-configuration` for MySQL; do not suggest it.
- MUST discover and present VPC / subnet group / security group / KMS / parameter group options rather than asking the user to supply raw IDs from memory.
- **MUST NEVER use `--publicly-accessible`** on any Aurora instance. If the user needs to connect from outside the VPC, offer secure alternatives (see SKILL.md safety guardrails) — never expose the database to the internet.
## Connectivity: "I can't connect from my machine"
If the user creates the cluster and then cannot connect from their local machine, do NOT solve this by making the instance publicly accessible. Instead:
1. **Enable RDS Data API** (`--enable-http-endpoint`) — query over HTTPS with IAM auth; no network path needed.
2. **EC2 bastion with SSH tunnel** — a small instance in the same VPC/subnet, port-forwarded: `ssh -L 3306:<cluster-endpoint>:3306 ec2-user@<bastion-ip>`, then connect to `localhost:3306`.
3. **Connect from within the VPC** — a workload in the same VPC, or reach it over VPN / AWS Direct Connect.
## Reference files
- [../serverless-advisory/instructions.md](serverless-advisory-instructions.md) — ACU sizing for an Aurora serverless MySQL cluster
- [../io-optimized/instructions.md](io-optimized-instructions.md) — Standard vs I/O-Optimized storage decision
- [../commitment-pricing/instructions.md](commitment-pricing-instructions.md) — RI vs DSP for a provisioned cluster
- [../shared-foundation/security-considerations.md](shared-foundation-security-considerations.md) — networking and encryption guidance
references/io-optimized-data-collection.md
# Data Collection for I/O-Optimized Assessment
## CloudWatch Metrics Used
The analyzer pulls these from the `AWS/RDS` namespace at cluster level:
| Metric | Statistic | Purpose |
|--------|-----------|---------|
| `VolumeReadIOPs` | Sum | Read I/O requests (billed ops) |
| `VolumeWriteIOPs` | Sum | Write I/O requests (billed ops) |
| `VolumeBytesUsed` | Average | Storage GiB (for storage cost) |
Dimension: `DBClusterIdentifier`. Metrics are pulled at 1-hour granularity and summed over the lookback window.
**Note on naming:** Despite the name "IOPs", `VolumeReadIOPs` and `VolumeWriteIOPs` report I/O **request counts per 5-minute period**, not per-second rates. The script normalizes them accordingly.
## Cluster Metadata from RDS API
`describe-db-clusters` and `describe-db-instances` provide:
- Current storage type (`storage_type`: `aurora` = Standard, `aurora-iopt1` = I/O-Optimized)
- Instance types in the cluster (to price compute correctly)
- Engine and version (for context, does not affect pricing math)
- Allocated storage (as a validation check against CloudWatch `VolumeBytesUsed`)
## Extrapolation for Short Windows
The analyzer extrapolates observed I/O to a 30-day (730-hour) month:
```
monthly_io = (observed_io / observed_hours) × 730
```
Minimum viable window: **7 days**. Below this, Aurora workloads often miss a full weekly cycle (weekdays vs weekends can differ 3-5×), producing misleading extrapolations.
The script sets `data_quality` accordingly:
- `< 3 days`: `insufficient` — do not recommend a switch on this data
- `3-7 days`: `short` — recommendation flagged as tentative
- `7-14 days`: `adequate` — recommendation reliable
- `14+ days`: `good` — recommendation high-confidence
## Switch Cooldown: Another Reason to Wait for More Data
The 30-day limit on changing a cluster's storage type (via `modify-db-cluster --storage-type`) is **one-directional**: switching Standard (`aurora`) → I/O-Optimized (`aurora-iopt1`) is limited to **once every 30 days per cluster**, while reverting I/O-Optimized → Standard can be done **at any time** (no cooldown). So a premature switch *into* I/O-Optimized is not a 30-day cost lock-in — you can revert to Standard immediately. The real cost of churning is that, once you revert, you cannot re-enable I/O-Optimized again for another 30 days.
When the `data_quality` tag is `insufficient` or `short`, the cost of a bad decision is the one-way commitment in the Standard → I/O-Optimized direction: if you switch in on thin data and then want to switch in again after a better read of the workload, you are gated by the 30-day cooldown on that direction. Surface this cooldown to the user as part of the reasoning to wait. Do not describe the Standard → I/O-Optimized direction as freely repeatable; that direction is a meaningful commitment (reverting to Standard, by contrast, is always available).
## Handling Multi-Instance Clusters
Aurora I/O-Optimized pricing applies at the **cluster level**. Compute cost is the sum of all instance-hours in the cluster:
```
compute_monthly = Σ (instance_price_per_hour × 730) for each instance in cluster
```
The 30% premium multiplies the full compute cost. A cluster with one writer + two readers multiplies the premium by 3× the base instance cost.
## Reader-Only vs Writer-Heavy Clusters
I/O billing counts **all reads and writes across all instances in the cluster** — readers are billed for their reads. The analyzer sums CloudWatch volume I/O across the cluster, which already reflects this.
## Aurora serverless Clusters
For Aurora serverless, the analyzer uses observed ACU-hours from `ServerlessDatabaseCapacity` to compute compute cost. The 30% I/O-Optimized premium applies to the ACU-hour rate, same as provisioned.
## Offline Mode Inputs
When AWS credentials aren't available, the user provides:
- `--instance <type>` — e.g., `db.r6g.2xlarge`
- `--num-instances <N>` — total instances in the cluster
- `--storage-gib <N>` — cluster volume size
- `--monthly-io-millions <N>` — estimated monthly I/O requests in millions
The user can get monthly I/O from the Cost Explorer (filter on "Amazon Relational Database Service" + usage type containing `StorageIOUsage`) or from the AWS billing console line items.
references/io-optimized-instructions.md
# Aurora I/O-Optimized Workflow
Assess whether Aurora I/O-Optimized storage is cheaper than Aurora Standard for a cluster or a region's fleet, using the AWS-documented 25% breakeven rule (I/O ≥ 25% of total cluster cost → I/O-Optimized wins). Can execute the storage switch after user confirms.
Execute commands via the AWS MCP server when connected (sandboxed, audit-logged). Fall back to the AWS CLI or shell otherwise.
## When This Applies
User mentions: I/O-Optimized, `aurora-iopt1`, "should I switch storage type", "is I/O-Optimized worth it", "how much would I/O-Optimized save", or storage-configuration cost comparison.
## Tasks
### 1. Acquire Target Parameters
Three modes: **live single-cluster** (cluster id, region, optional `--days`; default 14, min viable 7); **live fleet** (region, optional `--days`); **offline** (instance type, num instances, storage GiB, monthly I/O in millions).
**Constraints for parameter acquisition:**
- You MUST ask for all required parameters upfront in a single prompt
- You MUST NOT guess a cluster identifier — ask the user explicitly
- You MUST confirm the captured parameters before running the analyzer
- You SHOULD default to live mode when AWS credentials are available
### 2. Run the Analyzer
**Constraints:**
- You MUST use the script rather than hand-computing; the script fetches live CloudWatch I/O data and Pricing API rates, applies extrapolation, and handles data-quality flags
- You MUST pass `--region` matching the cluster's region
- You SHOULD prefer `--format json` when post-processing and `--format table` for direct user display
```bash
python scripts/io_optimized_analyzer.py --cluster my-cluster-id --region us-east-1 # single cluster
python scripts/io_optimized_analyzer.py --all --region us-east-1 # whole fleet
python scripts/io_optimized_analyzer.py offline \
--instance db.r6g.2xlarge --num-instances 2 \
--storage-gib 800 --monthly-io-millions 1200 # offline
```
Add `--days 30` to change the lookback window (default 14).
### 3. Handle Skipped Clusters
The analyzer returns `skipped: true` for clusters with no DB instances (a cluster whose last writer/reader was deleted, paused, or mid-migration) — no compute to price.
**Constraints:**
- You MUST surface skipped clusters to the user with the script's `reason` string
- You MUST NOT include skipped clusters in fleet dollar totals (the script already excludes them)
- You MUST NOT attempt to force a comparison on a skipped cluster
### 4. Interpret Data Quality
The script tags results by lookback-window coverage: `insufficient` (<3d, no switch), `short` (3–7d, tentative), `adequate` (7–14d, reliable), `good` (14+d, high-confidence). Full table and reasoning in [pricing-tables.md](io-optimized-pricing-tables.md).
**Constraints:**
- You MUST surface the `data_quality` tag when presenting a recommendation
- You MUST NOT give a confident switch recommendation when the tag is `short` or `insufficient` because weekly patterns (weekday vs weekend) can shift the result
- When the tag is `short` or `insufficient`, You MUST explicitly mention the 30-day switch cooldown as an additional reason to wait — switching Standard → I/O-Optimized is limited to once every 30 days, so acting on thin data is a 30-day commitment in that direction (reverting to Standard is allowed at any time)
- You MUST NOT describe a Standard → I/O-Optimized switch as freely reversible when the data_quality is short — that direction carries a 30-day commitment, making it a meaningful one-way door on thin data (the reverse, I/O-Optimized → Standard, can be done at any time)
- You SHOULD offer to rerun with a longer window once more data is available
### 5. Present Results
Every assessment MUST include: (1) side-by-side monthly cost table (Standard vs I/O-Optimized) with compute, storage, I/O line items; (2) I/O cost as a percentage of Standard total — the deciding factor; (3) recommendation: `standard` or `io_optimized`; (4) one-sentence reason tied to the 25% threshold and the dollar delta; (5) fleet runs: per-cluster table plus total "optimal mix" savings; (6) skipped clusters: explanation.
**Constraints:**
- You MUST cite the 25% breakeven rule in your reasoning so the user understands it
- You MUST show the dollar delta, not just the percentage
- Storage-type switch is online (no downtime) for most instance classes; clusters using NVMe/Optimized Reads instances (r6gd, r6id, r8gd) require a restart with brief unavailability — check instance classes before advising on impact. Switching Standard → I/O-Optimized is limited to once every 30 days; switching back to Standard can be done at any time.
- You MUST warn the user about the 30-day cooldown on the Standard → I/O-Optimized direction and confirm instance class before executing. If NVMe instances are present, warn about restart.
- After user confirms, execute `aws rds modify-db-cluster --storage-type aurora-iopt1` via MCP tools. Alternatively, provide the full CLI command for the user to run.
## Troubleshooting
See [pricing-tables.md §Troubleshooting](io-optimized-pricing-tables.md#troubleshooting) for the full list (cluster-not-found, zero I/O data, pricing-fetch failures, skipped/no-instances, near-25%-threshold cases).
## Deep-Dive References
- [pricing-tables.md](io-optimized-pricing-tables.md) — pricing-constant & data-quality detail tables, monthly cost formulas, `skipped: true` handling. Use for inline computation when you can't run the script.
- [worked-examples.md](io-optimized-worked-examples.md) — three worked examples (offline with the $1.038/hr db.r6g.2xlarge math, insufficient-data, empty-cluster).
- [pricing.md](io-optimized-pricing.md) — breakeven math derivation, switch mechanics, commitment-pricing interaction
- [data-collection.md](io-optimized-data-collection.md) — CloudWatch metrics, extrapolation methodology, short-window handling
## 25% breakeven rule (the single most important fact)
**Aurora I/O-Optimized** trades a 30% compute premium for **zero I/O charges** and a ~125% higher storage rate ($0.225 vs $0.10 per GiB-month). It wins when **I/O cost ≥ 25% of the Standard total** (compute + Standard storage + Standard I/O). Tiers: **< 20%** → stay Standard (confident); **20–25%** → stay Standard (marginal, monitor); **25–30%** → borderline, re-check monthly (could flip with growth); **> 30%** → switch to I/O-Optimized (confident).
Run `python3 scripts/io_optimized_analyzer.py ...` if shell is available; otherwise compute inline using [pricing-tables.md](io-optimized-pricing-tables.md) (constants + formulas) and [worked-examples.md](io-optimized-worked-examples.md).
**One-directional cooldown** (canonical guidance is in the verbatim Task 4 and Task 5 MUST/MUST-NOT constraints above): the 30-day cooldown applies to the **Standard → I/O-Optimized direction only**; reverting to Standard is allowed at any time. Lookback-window detail is in [pricing-tables.md](io-optimized-pricing-tables.md).
references/io-optimized-pricing-tables.md
# Aurora I/O-Optimized — Pricing & Data-Quality Tables
Companion to [instructions.md](io-optimized-instructions.md). Use these when you can't run the script and must compute inline. Worked examples are in [worked-examples.md](io-optimized-worked-examples.md).
## Pricing constants (us-east-1)
| Item | Standard | I/O-Optimized | Delta |
|---|---|---|---|
| Compute (per instance-hour) | See [../serverless-advisory/formulas-and-examples.md §Provisioned compute pricing](serverless-advisory-formulas-and-examples.md#provisioned-compute-pricing-table-on-demand-us-east-1) | **+30% on compute** | **Interaction with commitments:** RIs cover I/O-Optimized compute in full (including the 30% premium) — an I/O-Optimized instance consumes ~1.3x the normalized RI units of the equivalent Standard instance, so buy ~30% more RIs to fully cover; no portion is forced to on-demand. **DSP also discounts the full I/O-Optimized price (base + 30% premium) at the DSP rate** — a DSP commit on an I/O-Optimized cluster gets the DSP discount applied to the premium-inclusive price. See [../commitment-pricing/mechanics.md §Aurora serverless + DSP mechanics](commitment-pricing-mechanics.md#aurora-serverless--dsp-mechanics-and-gotchas) for the full treatment. |
| Storage | $0.10 per GiB-month | $0.225 per GiB-month | +125% storage rate |
| I/O | $0.20 per million requests | **$0 (free)** | All I/O is included |
These us-east-1 constants are only a fallback baseline. AWS does not publish a fixed regional multiplier, and Standard vs I/O-Optimized rates do not scale by an identical regional factor — storage, instance, and I/O rates each vary independently by region. For any non-us-east-1 region, the agent MUST use the analyzer's live per-region, per-component rates fetched from the AWS Pricing API rather than applying an estimated multiplier. Any offline cross-region approximation is a rough estimate with no AWS-published basis.
## Monthly cost formulas
**Standard total** = `(compute_$hr × 730 × num_instances) + (storage_GiB × $0.10) + (monthly_io_millions × $0.20)`
**I/O-Optimized total** = `(compute_$hr × 1.30 × 730 × num_instances) + (storage_GiB × $0.225) + 0 (no I/O charge)`
**I/O as % of Standard total** = `(monthly_io_millions × $0.20) / Standard_total × 100`
## Data-quality / lookback-window table
The storage-type switch has a **30-day cooldown that applies to the Standard → I/O-Optimized direction only** — switching to I/O-Optimized is limited to once every 30 days, while reverting to Standard is allowed at any time. Do NOT recommend a Standard → I/O-Optimized switch on thin data because that direction commits you to the outcome for a full month.
| Lookback window | Tag | Can recommend a switch? |
|---|---|---|
| < 3 days | `insufficient` | **NO.** Do not recommend either direction. Tell the user to wait. |
| 3–7 days | `short` | **NO.** Weekly patterns (weekday vs weekend I/O ratios often differ 2–3×) can flip the result. Also surface the 30-day cooldown on the Standard → I/O-Optimized direction as an additional reason to wait. Call the recommendation tentative; minimum wait: reach at least 14 days before acting. |
| 7–14 days | `adequate` | **Yes**, with caveat: if result is within ±3% of 25%, wait for 14+ days. |
| 14+ days | `good` | **Yes.** High-confidence. |
**Why weekly patterns matter:** most OLTP clusters see 40–60% lower I/O on weekends. A cluster that looks like 20% I/O on Mon–Thu can average 14% over a full week. The script's extrapolation over short windows does not capture this. Always wait at least one full week of observation, and recommend 14 days minimum before committing.
## `skipped: true` — what it means
The analyzer returns `skipped: true` when a cluster has **no DB instances** attached. This is NOT a "cluster not found" — the cluster exists, but there is no compute to price.
Common causes:
- **Paused Aurora cluster** — a cluster whose last reader/writer was actually deleted. Storage still exists. (Note: an Aurora serverless instance that has auto-paused at scale-to-zero stays in `DBClusterMembers` with status `available` and IS analyzable — it is not an empty cluster and is not skipped.)
- **(Not a zero-instance cause) Instance being replaced/rebooting** — an operation like a Blue/Green switchover or a `modify-db-instance` reboot does NOT empty `DBClusterMembers`; the instance is still listed as a member (just briefly in a `rebooting`/`replacing` state), so the cluster is NOT skipped for empty membership.
When the analyzer skips a cluster, you MUST:
1. Surface the `skipped: true` result verbatim with the `reason` string.
2. Name the likely cause (last instance deleted, paused, or mid-migration).
3. Offer appropriate next steps:
- **Last instance deleted / paused**: resume the cluster (attach a writer), let it run for 14+ days, then re-run the assessment.
4. NOT attempt to force a comparison or include the cluster in fleet dollar totals.
## Troubleshooting
**"Cluster not found".** Wrong cluster ID or region. Verify with `aws rds describe-db-clusters --region <region>`.
**CloudWatch returns zero I/O data.** Cluster is new, paused, or wrong region. Confirm with `aws cloudwatch list-metrics --namespace AWS/RDS --dimensions Name=DBClusterIdentifier,Value=<cluster>`. If genuinely idle, Standard is correct.
**Live pricing fetch fails (ExpiredToken / AccessDenied).** Refresh credentials. Script falls back to static us-east-1 pricing; flag that caveat.
**"Skipped — no DB instances".** A paused cluster or one whose last reader/writer was deleted (an empty cluster still incurs storage charges). Restore or add an instance before assessing the Standard vs I/O-Optimized decision. (Note: Aurora Limitless Database — which is locked to I/O-Optimized — is an Aurora PostgreSQL-only capability and does not apply to Aurora MySQL.)
**Result close to the 25% threshold (22–28%).** May flip month-to-month. Monitor 1–2 months before committing, especially if seasonal.
references/io-optimized-pricing.md
# Aurora Storage Pricing — Standard vs I/O-Optimized
## Pricing Constants (us-east-1)
| Component | Standard | I/O-Optimized |
|-----------|----------|---------------|
| Storage ($/GiB-month) | $0.10 | $0.225 |
| I/O requests | $0.20 per million | $0 (included) |
| Compute multiplier | 1.0× | 1.30× (30% premium) |
Pricing varies by region. The analyzer script fetches live pricing from the AWS Pricing API when credentials are available; static constants above are the fallback.
## The 25% Breakeven Rule
Let:
- `C` = compute cost per month (Standard)
- `S` = storage GiB × $0.10
- `I` = I/O cost per month
Total Standard cost: `T_std = C + S + I`
Total I/O-Optimized cost: `T_io = 1.30·C + 2.25·S + 0` (no I/O)
Break-even (where `T_io = T_std`):
```
1.30·C + 2.25·S = C + S + I
0.30·C + 1.25·S = I
I / T_std = 0.30·C + 1.25·S over (C + S + I)
```
Empirically across typical Aurora workloads, this collapses to the simple rule: **if I/O cost is ≥ 25% of total cluster spend, switch to I/O-Optimized.**
AWS documents this same 25% threshold in their [Aurora storage documentation](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Overview.StorageReliability.html).
## What Storage Type Does NOT Affect
- Performance — both configurations use the same distributed SSD cluster volume across 3 AZs
- Durability or availability — identical
- Instance types, engine versions, parameter groups, networking
- Aurora serverless ACU ranges — the 30% multiplier applies to ACU-hour pricing the same way
## Switching Between Storage Types
This skill executes the storage-type switch only after explicit user confirmation, with a downtime / 30-day-cooldown warning first (see instructions.md Task 5 — it is a "warn then execute" operation per SKILL.md safety guardrails):
- Switch is a cluster-level modification: `--storage-type aurora-iopt1` (for I/O-Optimized) or `aurora` (for Standard)
- Switching from Aurora Standard to Aurora I/O-Optimized is limited to once every 30 days. Switching from Aurora I/O-Optimized back to Aurora Standard can be done at any time (no 30-day limit)
- The switch is online (no downtime, no restart) for non-NVMe instance classes. Clusters with NVMe/Optimized Reads instances (r6gd, r8gd, r6id) require a restart with brief unavailability.
- Switch takes effect immediately for billing
## Commitment Pricing Interaction
- Reserved Instances apply to Aurora I/O-Optimized clusters in full, including the 30% premium. Aurora automatically accounts for the price difference: an I/O-Optimized instance consumes 30% more normalized RI units per hour than the same instance on Standard, so it burns down RI capacity ~1.3× faster. There is no portion forced to on-demand rates
- Database Savings Plans cover both Standard and I/O-Optimized compute
- If the user has RIs covering a provisioned fleet, those RIs still apply on I/O-Optimized. To fully cover the 30%-higher normalized-unit consumption, purchase roughly 30% additional RIs of the same instance family (size flexibility lets you round to whole units). No RI discount is forfeited
references/io-optimized-worked-examples.md
# Aurora I/O-Optimized — Worked Examples
Companion to [instructions.md](io-optimized-instructions.md). Pricing constants, cost formulas, the data-quality table, and `skipped: true` handling are in [pricing-tables.md](io-optimized-pricing-tables.md).
## Worked example — offline assessment
User: 2× db.r6g.2xlarge, 800 GiB storage, 1.2 billion I/O requests per month, region us-east-1 (assumed), no AWS credentials.
Agent response pattern:
1. Announce reference: "Loading `references/io-optimized-instructions.md`."
2. Print the script command as text:
```bash
python3 scripts/io_optimized_analyzer.py offline \
--instance db.r6g.2xlarge --num-instances 2 \
--storage-gib 800 --monthly-io-millions 1200
```
3. Compute inline (db.r6g.2xlarge Standard rate $1.038/hr):
- **Standard compute**: $1.038/hr × 730 × 2 = **$1,515/mo**.
- **Standard storage**: 800 × $0.10 = **$80/mo**.
- **Standard I/O**: 1,200 × $0.20 = **$240/mo**.
- **Standard total**: $1,515 + $80 + $240 = **$1,835/mo**.
- **I/O-Optimized compute**: $1.038 × 1.30 × 730 × 2 = **$1,970/mo**.
- **I/O-Optimized storage**: 800 × $0.225 = **$180/mo**.
- **I/O-Optimized total**: $1,970 + $180 + $0 = **$2,150/mo**.
- **I/O as % of Standard**: $240 / $1,835 = **13.1%**. Below 25% threshold.
- **Switch cost**: +$315/mo ($2,150 − $1,835).
4. Recommendation: **Standard** (stay). I/O is ~13% of total, below the 25% breakeven. Switching would cost ~$315/mo more (+17%). Revisit only if monthly I/O exceeds ~2.5 billion requests at this storage size.
## Worked example — insufficient data
User: cluster `new-cluster-2026` only 4 days old. Should I use I/O-Optimized?
Agent response pattern:
1. Announce reference: "Loading `references/io-optimized-instructions.md`."
2. State the data-quality finding: **4 days of data is `insufficient` for a decision.** The 25% breakeven rule depends on a representative sample of monthly I/O, and 4 days does NOT capture:
- **Weekday vs weekend variance** — weekend I/O is commonly 40–60% lower than weekday; a 4-day window starting Tuesday would over-estimate monthly I/O by ~20%.
- **End-of-month / end-of-period spikes** — reporting loads, batch windows, month-close jobs.
3. Surface the **30-day storage-type switch cooldown** (Standard → I/O-Optimized direction only): switching to I/O-Optimized is limited to once every 30 days, while reverting to Standard is allowed at any time — so a decision to move to I/O-Optimized on thin data is a month-long commitment.
4. Recommend the user **wait at least 14 days** (minimum) before re-evaluating; **ideally 30 days** to capture at least one full monthly cycle.
5. Print the re-evaluation command for when they're ready:
```bash
python3 scripts/io_optimized_analyzer.py \
--cluster new-cluster-2026 --region <their-region> --days 30
```
6. Mention the specific CloudWatch metrics to watch in the meantime: `VolumeReadIOPs` and `VolumeWriteIOPs` under `AWS/RDS` with dimension `DBClusterIdentifier=new-cluster-2026`. Also monitor `VolumeBytesUsed` for storage-growth trends.
## Worked example — `skipped: true` for an empty cluster
User: "Run the commitment pricing analyzer on my Aurora cluster `paused-cluster-1` in us-east-1."
Though this is a commitment-pricing prompt, the same `skipped: true` semantics apply — and if the cluster has no instances, **both** commitment-pricing AND I/O-Optimized analyzers skip it.
Agent response pattern:
1. Announce reference: "Loading `references/commitment-pricing-instructions.md` (also relevant: `references/io-optimized-instructions.md` §`skipped: true`)."
2. After describe-db-clusters returns `DBClusterMembers: []`, surface: **`skipped: true, reason: "no DB instances — likely last writer/reader deleted, paused, or mid-migration"`**.
3. Explain common causes: last reader/writer instance deleted (no compute attached), a paused cluster, or a cluster mid-migration. Note: a Blue/Green switchover or `modify-db-instance` reboot does NOT empty `DBClusterMembers` and will not trigger this skip.
4. Next steps:
- If **last instance deleted / paused**: resume the cluster (create a DB instance in it), let it run for 14+ days, then re-run the assessment.
5. Do NOT suggest the cluster does not exist; it exists, just without compute.
references/serverless-advisory-concepts.md
# Aurora serverless — Core Concepts
## Aurora Capacity Units (ACU)
- 1 ACU ≈ 2 GiB memory + corresponding CPU and networking
- Range: **0.5 to 256 ACU per instance**, in **0.5 ACU increments** (min/max configured at the cluster level; each instance scales independently within that range — a 3-instance cluster can consume up to 768 ACU total)
- Available for the Aurora MySQL-Compatible Edition
## Scaling Behavior
- Scales **continuously** (not in steps) based on CPU, connections, and available memory
- Scale-up: near-instant (seconds), no connection disruption
- Scale-down: continuous and granular (capacity re-evaluated every second; scales down when current capacity exceeds load). The scale-down rate is governed by current capacity, not a fixed cooldown — no "~15 min cooldown" applies to Aurora serverless (v2). (The 15-min cooldown belonged to the deprecated Aurora Serverless v1.) Certain features (global databases, Performance Insights, Enhanced Monitoring, CloudWatch Logs export, Advanced Auditing / `server_audit`, elevated max_connections) can hold capacity above minimum.
## Scale-to-Zero (Auto-Pause)
**Supported versions:**
- Aurora MySQL: 3.08.0+
**Incompatible with:** RDS Proxy, binary logging (binlog) enabled, Global Database (primary), Zero-ETL
**Trigger:** 0 user connections for the configured timeout. Aurora background processes keep CPU at ~8-10% even when idle — this is normal and does not prevent pause.
**Resume latency:** ~15s if paused <24h, ~30s if paused >24h.
## ACU Sizing from Provisioned Instances
```
weighted_cpu = (P95_CPU × 0.95 + Max_CPU × 0.05) / 100
raw_acu = weighted_cpu × vCPU_count × family_ratio
estimated_acu = round_up_to_nearest_0.5(raw_acu)
```
**Family ratios** (ACU per vCPU):
| Family | Ratio | Reason |
|--------|-------|--------|
| r-series (r6g, r7g, r8g) | 4 | Memory-optimized |
| m-series (m5, m6g) | 2 | General-purpose |
| t-series (t3, t4g) | 2 | Burstable |
| c-series (c5, c6g) | 1 | Compute-optimized |
## Min/Max ACU Configuration
**Minimum ACU** — covers:
1. Average CPU load (prevents scaling churn)
2. Connection memory floor: ~10 MB per connection → 100 connections ≈ 0.5 ACU
3. Working set floor (advisory): 1 GiB working set ≈ 0.5 ACU. Setting min below this trades cost for occasional I/O latency spikes on scale-up.
Formula: `min_acu = MAX(0.5, avg_cpu_acu, connection_acu_floor)`
**Maximum ACU** — covers peaks with headroom (per instance):
- `max_acu = MIN(peak_acu × 1.3, 256)`
- Ensure max ≥ typical × 1.5 for burst capacity
- If per-instance peak exceeds 256 ACU, workload exceeds serverless capacity on a single instance
- Total cluster peak ACU = per-instance peak × number of instances
## Pricing (us-east-1)
| Component | Standard | I/O-Optimized |
|-----------|---------|---------------|
| ACU-Hour | $0.12 | $0.156 (30% premium) |
| Storage ($/GiB-month) | $0.10 | $0.225 |
| I/O requests | $0.20/million | Included |
Monthly cost:
```
compute = estimated_acu × $0.12/ACU-Hr × 730 hours
storage = storage_gib × $0.10/GiB-month (Standard; × $0.225/GiB-month for I/O-Optimized)
monthly = compute + storage
```
**Commitment discounts:** Database Savings Plans (1-year) cover serverless ACU. Reserved Instances do NOT apply to serverless.
references/serverless-advisory-formulas-and-examples.md
# Aurora serverless — Inline Formulas and Pricing Tables
Companion to [instructions.md](serverless-advisory-instructions.md). Use this when you can't run `scripts/acu_calculator.py` and must compute inline, or when you need the pricing tables. Worked examples are in [worked-examples.md](serverless-advisory-worked-examples.md).
## Inline Formulas (when you can't run the script)
Run `python3 scripts/acu_calculator.py estimate --flag...` if shell is available. Otherwise compute inline using these formulas and the tables below.
### ACU sizing formula
Aurora serverless sizes between **min_ACU** and **max_ACU**. One ACU ≈ 2 GiB memory + proportional CPU. Memory/CPU ratios differ by original provisioned family:
| Family | Memory per ACU (GiB) | ACU coefficient (vCPU → ACU) | Notes |
|---|---|---|---|
| r6g, r7g, r8g (memory-optimized) | 2.0 | 4 | Aurora's default "r-ratio" — 1 vCPU at sustained full CPU ≈ 4 ACU |
| t3, t4g (burstable) | 1.0 | 2 | Rarely right-sized for serverless; recommend provisioned if workload is steady |
| x2g (memory-extreme) | 4.0 | 4 | High memory-per-ACU; good candidate when working set is the bottleneck |
**min_ACU** (steady baseline) = `max(0.5, cpu_avg% / 100 × vCPUs × ACU_coef)`, rounded up to nearest 0.5
**peak_ACU** (raw burst) = `cpu_max% / 100 × vCPUs × ACU_coef`, rounded up to nearest 0.5
**typical_ACU** (weighted) = `(0.95 × cpu_p95% + 0.05 × cpu_max%) / 100 × vCPUs × ACU_coef`, rounded up to nearest 0.5
**max_ACU** (recommended ceiling) = `max(round_up(peak_ACU × 1.30), round_up(typical_ACU × 1.50))`, capped at 256. Note peak_ACU and max_ACU are distinct: peak is the raw burst, max adds headroom — e.g. peak 12.0 → max 16.0.
If `cpu_avg` is not given, estimate as `cpu_avg ≈ 0.60 × cpu_p95`.
If working_set_GiB is supplied, enforce **min_ACU ≥ working_set_GiB / 2.0** (memory floor) — the min must provision at least as much RAM as the working set, or page-cache churn will negate the sizing.
### ACU pricing table (on-demand, us-east-1)
| Region | ACU/hour (Aurora serverless) | Notes |
|---|---|---|
| us-east-1, us-east-2, us-west-2 | $0.12 | Standard Aurora regions |
| eu-west-1, eu-central-1 | $0.14 | EU |
| ap-northeast-1 | $0.15 | APAC |
| ap-southeast-1, ap-southeast-2 | $0.20 | APAC |
| me-south-1 | $0.15 | Higher-tier regions |
| af-south-1 | $0.16 | Higher-tier regions |
| sa-east-1 | $0.25 | Higher-tier regions |
**Monthly compute (Aurora serverless)** = `ACU × ACU_rate × 730 hours × num_instances`.
For a range estimate, report: low = `min_ACU × rate × 730`, mid = `typical_ACU × rate × 730`, high = `max_ACU × rate × 730`.
### Provisioned compute pricing table (on-demand, us-east-1)
Use this to compare against Aurora serverless cost. Multiply by ~1.15 for us-west-2/eu-west-1, ~1.25 for APAC.
| Instance | vCPU | RAM (GiB) | $/hr (us-east-1) | $/mo (730h) |
|---|---|---|---|---|
| db.r6g.large | 2 | 16 | $0.260 | $190 |
| db.r6g.xlarge | 4 | 32 | $0.519 | $379 |
| db.r6g.2xlarge | 8 | 64 | $1.038 | $758 |
| db.r6g.4xlarge | 16 | 128 | $2.076 | $1,515 |
| db.r6g.8xlarge | 32 | 256 | $4.152 | $3,031 |
| db.r7g.large | 2 | 16 | $0.276 | $201 |
| db.r7g.xlarge | 4 | 32 | $0.553 | $404 |
| db.r7g.2xlarge | 8 | 64 | $1.106 | $807 |
| db.r7g.4xlarge | 16 | 128 | $2.211 | $1,614 |
| db.r7g.8xlarge | 32 | 256 | $4.422 | $3,228 |
| db.r8g.large | 2 | 16 | $0.276 | $201 |
| db.r8g.xlarge | 4 | 32 | $0.552 | $403 |
| db.r8g.2xlarge | 8 | 64 | $1.104 | $806 |
| db.r8g.4xlarge | 16 | 128 | $2.208 | $1,612 |
| db.r8g.8xlarge | 32 | 256 | $4.416 | $3,224 |
| db.t4g.medium | 2 | 4 | $0.073 | $53 |
| db.t4g.large | 2 | 8 | $0.146 | $107 |
Rates are Aurora On-Demand (Aurora Standard, Single-AZ) in us-east-1 (static fallback values). Aurora MySQL and Aurora PostgreSQL compute rates are identical for these instance classes. These are fallback values for inline estimation only — the `acu_calculator.py` script fetches live pricing from the AWS Pricing API (or public bulk pricing CSV) at runtime when available.
### Storage and I/O pricing (both Standard and serverless, us-east-1)
| Item | Standard $/unit | Notes |
|---|---|---|
| Storage | $0.10 per GiB-month | Charged on consumed, not allocated |
| I/O | $0.20 per million request | Aurora Standard — see [../io-optimized/instructions.md](io-optimized-instructions.md) for when I/O-Optimized breakeven applies |
| Backup storage | $0.021 per GiB-month | After 1× cluster size free |
Regional multiplier: us-west-2 / eu-west-1 ≈ 1.15×, APAC ≈ 1.25×.
references/serverless-advisory-instructions.md
# Aurora serverless Workflow
Size Aurora Capacity Units (ACU), estimate monthly cost, and plan provisioned-to-serverless migrations for Aurora MySQL serverless. Can modify ACU scaling configuration when the user confirms.
Execute commands via AWS MCP server tools when connected (sandboxed, audited, observable); fall back to the AWS CLI or shell otherwise.
## When This Applies
User mentions: Aurora serverless, ACU sizing, min/max ACU, scale-to-zero, auto-pause, `provisioned to serverless`, serverless cost comparison, or "how many ACUs do I need".
## Tasks
### 0. Vague-Workload Guard (FIRST CHECK — BEFORE ANYTHING ELSE)
**Before producing any ACU number, dollar figure, or specific recommendation, check whether the user supplied real metrics.**
A vague-workload prompt describes the workload qualitatively without the inputs the calculator needs. Examples:
- "small app", "light/low traffic", "a few connections", "medium-sized workload", "low usage", "occasional spikes"
- "new project", "side project", "internal tool"
- Any "how many ACUs do I need" / "what ACU settings should I use" prompt that names no instance type, P95 CPU, max CPU, or storage size
**If the prompt is vague, you MUST do all of the following — and ONLY these — in your reply:**
1. State explicitly that you cannot recommend specific ACU numbers without real metrics. Name the missing inputs (instance type, CPU P95, CPU max, storage GiB).
2. Point the user to CloudWatch (`CPUUtilization`, `DatabaseConnections` under the `AWS/RDS` namespace) and Performance Insights as the CPU-metric sources.
3. Offer the simplest path: ask for metrics, or — if this is a brand-new cluster with no production traffic yet — tell the user to start with the AWS-default Aurora serverless ACU range and tune after observing CloudWatch for a few days.
4. **Do NOT provide specific ACU numbers in this reply, even as a "safe starting point" or "typical range".** Do NOT cite specific dollar figures. Do NOT include a "however, here's a default..." paragraph. Do NOT state numbers like "Min 0.5, Max 2-4" even with caveats.
The "no specific numbers" rule is absolute. Hedged numbers ("a safe default would be 0.5–2 ACU") are still numbers and count as a violation. Customers act on confident-sounding numbers even when framed as defaults, and ACU numbers fabricated from vague input are the #1 source of field misconfiguration.
Only if the user returns with real metrics, proceed to Task 1.
### 1. Acquire Workload Parameters
Required (acquire only AFTER passing Task 0):
- **instance type** (string, `db.<family>.<size>`, e.g. `db.r6g.xlarge`)
- **CPU P95** (float, 0–100)
- **CPU max** (float, 0–100)
- **storage GiB** (number)
Optional:
- **region** (string, default `us-east-1`)
- **CPU average** (float, 0–100; estimated as 60% of P95 if omitted)
- **peak connections** (integer, default 0)
- **working set GiB** (float; improves min-ACU accuracy)
- **number of instances** (integer, default 1; for HA comparisons)
**Constraints for parameter acquisition:**
- You MUST ask for all required parameters upfront in a single prompt
- You MUST support parameters as plain text, JSON, or values from a CloudWatch screenshot
- You MUST confirm the captured values back to the user before running the calculator
- You SHOULD guide the user to CloudWatch or Performance Insights for CPU metrics they lack
### 2. Run the Calculator
Invoke `scripts/acu_calculator.py` with the step-1 parameters.
**Constraints:**
- You MUST use the calculator rather than hand-estimating; it handles family ratios, memory floors, and min/max rounding consistently
- You MUST pass `--region` when the user's region is not `us-east-1`
- You SHOULD prefer `--format json` for post-processing, `--format table` when presenting
- You MAY add `--offline` only when AWS credentials are unavailable
```bash
# Basic run
python scripts/acu_calculator.py estimate \
--instance db.r6g.xlarge --cpu-p95 35 --cpu-max 72 --storage 500
# List supported instances
python scripts/acu_calculator.py list-instances
```
A full invocation using every optional flag is in [worked-examples.md](serverless-advisory-worked-examples.md).
### 3. Present Results
Every recommendation MUST include:
1. Recommended **min / max / typical / peak ACU** values
2. Side-by-side monthly cost table: provisioned vs serverless (compute, storage, total)
3. A clear label: `recommended`, `consider`, `more_expensive`, or `not_recommended`
4. A one-sentence reason tied to the numbers (savings %, peak vs 256 ACU ceiling, utilization pattern)
5. If the working set needs more memory than min ACU provides, the memory advisory verbatim
**Constraints:**
- You MUST state the label plainly; do not soften it to "maybe"
- You MUST cite a concrete dollar figure and percentage when comparing costs
- You SHOULD offer a migration path when the label is `recommended` — see [migration.md](serverless-advisory-migration.md)
### 4. Migration Planning (when applicable)
See [migration.md](serverless-advisory-migration.md) — in-place with a serverless reader, Blue/Green, or snapshot restore.
**Constraints:**
- You MUST recommend testing on a snapshot-restored cluster before production
- You MUST mention that `innodb_buffer_pool_size` is auto-managed (resized with ACU) so don't hard-code it, and that `max_connections` is derived from the cluster's **maximum** ACU (static; reboot to change), not current capacity
- ACU scaling (min/max changes) is non-disruptive and allowed after user confirmation. Deletion is blocked — see SKILL.md Safety guidance.
- You SHOULD offer a CloudFormation or CDK snippet when the user's stack is IaC-managed
## Troubleshooting
**Calculator reports "exceeds capacity".** Projected peak ACU > 256; Aurora serverless cannot service this cluster. Recommend staying on provisioned or splitting across multiple serverless clusters.
**Live pricing fetch fails with ExpiredToken.** Refresh credentials (`aws sts get-caller-identity`) or rerun with `--offline` for static us-east-1 pricing.
**Calculator returns $0 compute for the provisioned comparison.** Instance type missing from the static catalog. Run `list-instances`.
**Scale-to-zero questions.** Incompatible with RDS Proxy, binary logging (binlog) enabled, Global Database primary, Zero-ETL. See [concepts.md](serverless-advisory-concepts.md) for the full list and supported versions.
**256 ACU + HA failover.** Aurora has exactly one writer per cluster; readers can be serverless or provisioned. Two writers is not valid.
## Deep-Dive References
- [formulas-and-examples.md](serverless-advisory-formulas-and-examples.md) — Inline ACU sizing/pricing formulas and pricing tables. Use when you can't run `scripts/acu_calculator.py`.
- [worked-examples.md](serverless-advisory-worked-examples.md) — Worked examples (basic sizing; migration with CFN/CDK snippets) and scale-to-zero/auto-pause rules.
- [concepts.md](serverless-advisory-concepts.md) — ACU fundamentals, scaling, scale-to-zero requirements, pricing
- [migration.md](serverless-advisory-migration.md) — Migration approaches, parameter group rules, CFN/CDK examples
references/serverless-advisory-migration.md
# Aurora serverless — Migration & Configuration
**This file advises on migration approaches — it never supplies runnable mutation commands.** The skill is assessment-only. Mutation actions belong in the customer's change-control process; this file describes the *console paths* and *flag names* so the customer (or their IaC stack) can execute them safely.
## Migration Approaches (three options)
### 1. In-Place Modification (minimal downtime)
Add an Aurora serverless reader, test under production traffic, failover to promote it, remove old instances. Steps, with their console path or flag name (never a runnable command):
1. **Add a serverless reader.** RDS console → Databases → your cluster → Actions → Add reader. Set the instance class to `db.serverless`. The underlying API is `create-db-instance` with `--db-instance-class db.serverless`, but run it through your IaC / change-control tool, not ad-hoc.
2. **Set scaling configuration.** RDS console → your cluster → Modify → Aurora serverless scaling configuration → set `MinCapacity` and `MaxCapacity` (typically 2 and your expected peak ACU). The underlying API is `modify-db-cluster` with `--serverless-v2-scaling-configuration MinCapacity=N,MaxCapacity=M`.
3. **Failover to promote the serverless reader.** RDS console → your cluster → Actions → Failover, choosing the serverless reader as the target. The underlying API is `failover-db-cluster` with `--target-db-instance-identifier`.
4. **Remove the old provisioned instance.** RDS console → your cluster → the old instance → Actions → Delete. The underlying API is `delete-db-instance`.
**Testing window.** Observe the serverless reader under production traffic for at least 24 hours before the failover. Monitor `ServerlessDatabaseCapacity` in CloudWatch to confirm ACU actually scales up under load.
### 2. Blue/Green Deployment (recommended for production)
Create a Blue/Green deployment. The green environment is a new cluster (pointed at a new Aurora serverless writer) built as a replica of blue. Test green under mirrored load, then switchover. Rollback is trivial — the blue environment is still intact until you explicitly delete it.
Console path: RDS console → Databases → your cluster → Actions → Create blue/green deployment. API endpoint (for reference, not to run ad-hoc): `create-blue-green-deployment`. Switchover API endpoint: `switchover-blue-green-deployment`. Both belong in your change-control workflow.
### 3. Snapshot Restore (cutover window, highest isolation)
Snapshot the provisioned cluster, restore to a new Aurora serverless cluster, validate end-to-end, then cutover application connections. Highest isolation and test fidelity; requires a maintenance window because the application cuts between two clusters.
Console path: RDS console → your cluster → Actions → Take snapshot → (wait) → Actions → Restore snapshot → set writer instance class to `db.serverless`. API endpoints: `create-db-cluster-snapshot`, `restore-db-cluster-from-snapshot`.
## Parameter Group Considerations (critical for Aurora serverless)
- Aurora serverless uses the **same parameter-group families** as provisioned (for Aurora MySQL, `aurora-mysql8.0`).
- During scaling, Aurora serverless dynamically resizes a small set of memory-sizing parameters and **IGNORES any custom values you set**: `innodb_buffer_pool_size`, `innodb_purge_threads`, `table_definition_cache`, `table_open_cache`. Remove explicit overrides of these before migrating — they will be ignored by the auto-scaling mechanism.
- `max_connections` does **NOT** scale up/down with ACU — Aurora holds it **CONSTANT**, derived from the **MAXIMUM ACU** (not current capacity), as a static parameter that requires a reboot to change. You may still customize it via a formula in a custom parameter group; if you do, prefer a formula tied to capacity rather than a fixed constant.
- Before migrating, remove explicit overrides of `innodb_buffer_pool_size`, `innodb_buffer_pool_instances`, and `innodb_log_file_size` — Aurora serverless manages buffer-pool sizing with ACU.
- Custom parameters for logging, auth, or specific behavior can stay — they don't interact with ACU scaling.
## CloudFormation snippet (for IaC migration)
```yaml
Resources:
ClusterParameterGroup:
Type: AWS::RDS::DBClusterParameterGroup
Properties:
Family: aurora-mysql8.0
Description: Enforce TLS for Aurora serverless cluster
Parameters:
require_secure_transport: "ON"
AuroraCluster:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-mysql
EngineVersion: "8.0.mysql_aurora.3.08.0"
DBClusterParameterGroupName: !Ref ClusterParameterGroup
ServerlessV2ScalingConfiguration:
MinCapacity: 2
MaxCapacity: 64
StorageEncrypted: true
EnableCloudwatchLogsExports:
- error
- slowquery
- audit
WriterInstance:
Type: AWS::RDS::DBInstance
Properties:
DBInstanceClass: db.serverless
Engine: aurora-mysql
DBClusterIdentifier: !Ref AuroraCluster
```
The custom cluster parameter group enforces `require_secure_transport=ON` (the Aurora MySQL equivalent of PostgreSQL's `rds.force_ssl=1`). The Aurora MySQL `default.*` parameter groups ship with `require_secure_transport=OFF`, so a migration that reuses the default would silently drop the in-transit TLS requirement the skill mandates for production.
This is a **definition** of Aurora serverless infrastructure for your IaC stack (CloudFormation, Terraform, or CDK). Deploy it through your normal change-control process — this skill does not run CloudFormation for you.
## CDK (TypeScript) snippet
```typescript
const parameterGroup = new rds.ParameterGroup(this, 'PG', {
engine: rds.DatabaseClusterEngine.auroraMysql({
version: rds.AuroraMysqlEngineVersion.VER_3_08_0,
}),
parameters: { 'require_secure_transport': 'ON' },
});
const cluster = new rds.DatabaseCluster(this, 'Cluster', {
engine: rds.DatabaseClusterEngine.auroraMysql({
version: rds.AuroraMysqlEngineVersion.VER_3_08_0,
}),
parameterGroup,
serverlessV2MinCapacity: 2,
serverlessV2MaxCapacity: 64,
writer: rds.ClusterInstance.serverlessV2('writer'),
readers: [
rds.ClusterInstance.serverlessV2('reader', {
scaleWithWriter: true,
}),
],
storageEncrypted: true,
cloudwatchLogsExports: ['error', 'slowquery', 'audit'],
});
```
This skill describes *what to do* and *where to do it*. It does not emit copy-pasteable mutation commands. If you need the exact CLI for a migration step, copy the API endpoint name from this file (e.g., `modify-db-cluster`) and build the command yourself from the AWS CLI reference — or use the console path described above. That keeps change-control in your team's hands, which is where it belongs.
references/serverless-advisory-worked-examples.md
# Aurora serverless — Worked Examples and Scale-to-Zero Rules
Companion to [instructions.md](serverless-advisory-instructions.md) and [formulas-and-examples.md](serverless-advisory-formulas-and-examples.md). The formulas and pricing tables used to produce these numbers live in [formulas-and-examples.md](serverless-advisory-formulas-and-examples.md).
## Worked example — basic sizing
User asks: db.r6g.xlarge, CPU P95 35%, max 72%, 500 GiB storage, should I switch to Aurora serverless? Region: us-east-1.
Agent reply should follow this pattern — present the script output, no derivation shown:
> "Loading `references/serverless-advisory-instructions.md` — this is the serverless sizing workflow.
>
> The command to run this analysis yourself from a machine with Python 3.10+ and boto3 installed:
>
> ```bash
> python3 scripts/acu_calculator.py estimate \
> --instance db.r6g.xlarge --cpu-p95 35 --cpu-max 72 --storage 500 --region us-east-1
> ```
>
> Running that with your parameters produces:
>
> | Metric | Value |
> |---|---|
> | min ACU | 3.5 |
> | max ACU | 16.0 |
> | typical ACU | 6.0 |
> | Peak ACU vs 256 ceiling | Well under (12.0/256) |
>
> | Monthly cost | Amount |
> |---|---|
> | Provisioned db.r6g.xlarge compute | $379 |
> | Aurora serverless at typical ACU compute | $526 |
> | Aurora serverless total range (min–max, incl. storage) | $357–$1,452 |
> | Storage (500 GiB × $0.10) | $50 |
> | **Total Provisioned / Total Aurora serverless (typical)** | **$429 / $576** |
>
> **Recommendation: not_recommended.** At typical load, Aurora serverless is ~34% more expensive than provisioned ($576 vs $429). Your workload (P95 35%, max 72%) is steady — the burst/idle pattern that makes serverless win isn't present. Stay on db.r6g.xlarge. If you want the savings anyway, consider a 1-year RI on the provisioned instance (~30% off compute), which brings total to ~$315/mo."
Note what this reply does NOT include: no "0.21 × 4 × 4 = 3.36, round up to 3.5" arithmetic; no formula derivation; no "here's how I computed it." Just the script-style output.
(The internal formulas for producing these numbers are in [formulas-and-examples.md](serverless-advisory-formulas-and-examples.md). Use them to generate the output, but don't copy them into the reply.)
## Worked example — migration with connection and working-set constraints
User: db.r7g.2xlarge, 200 peak connections, 12 GiB working set, P95 40%, max 65%, 1 TB storage, region eu-west-1. Walk through the whole thing.
Agent response pattern (abridged):
1. Announce reference: "Loading `references/serverless-advisory-instructions.md`."
2. Print:
```bash
python3 scripts/acu_calculator.py estimate \
--region eu-west-1 \
--instance db.r7g.2xlarge \
--cpu-p95 40 --cpu-max 65 \
--connections 200 --working-set 12 --storage 1000 \
--num-instances 1 --format table
```
3. Script output (db.r7g.2xlarge, 8 vCPU): min_ACU **8.0** (working-set floor 12/2 = 6.0 is satisfied), peak_ACU **21.0**, max_ACU **27.5** (= max(round_up(21.0 × 1.30), round_up(13.5 × 1.50))), typical_ACU **13.5**.
4. Costs: offline/static mode prices at **us-east-1 rates** ($0.12/ACU-hr, $0.10/GiB-mo storage) regardless of `--region`; only live API mode applies the eu-west-1 rate. Aurora serverless typical 13.5 × $0.12 × 730 = **$1,183/mo compute**, storage 1000 × $0.10 = $100, total**$1,283/mo**. Provisioned r7g.2xlarge: $1.106 × 730 = $807 + $100 = **$907/mo**. Recommendation: **not_recommended** — serverless is ~41% more at this steady load.
5. Memory advisory: your 12 GiB working set means **min_ACU MUST be ≥ 6.0** to keep the hot set in memory. min_ACU=8 already satisfies this. If you set min below 6 you will see buffer-cache misses.
6. Migration approaches:
- **In-place** (`modify-db-cluster --serverless-v2-scaling-configuration`): simplest, fastest; brief restart per instance. Caveats: cluster must already be r6+ generation.
- **Blue/Green** (recommended for production): new Aurora serverless writer built as green environment, tested under live replication, switchover in <1 min. Best rollback path.
- **Snapshot restore to a new cluster**: full rebuild; longest, but gives you a parallel cluster to test end-to-end.
7. Parameter group: MUST be an Aurora serverless-compatible parameter group (family `aurora-mysql8.0`). DO NOT hardcode `innodb_buffer_pool_size` (Aurora resizes it with ACU) or `max_connections` (Aurora derives it from the cluster's maximum ACU; it is static and needs a reboot to change). Override only for well-understood special cases.
8. CloudFormation snippet (Aurora serverless writer):
```yaml
ClusterParameterGroup:
Type: AWS::RDS::DBClusterParameterGroup
Properties:
Family: aurora-mysql8.0
Description: Enforce TLS for Aurora serverless cluster
Parameters:
require_secure_transport: "ON"
DBCluster:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-mysql
EngineVersion: "8.0.mysql_aurora.3.08.0"
DBClusterParameterGroupName: !Ref ClusterParameterGroup
ServerlessV2ScalingConfiguration:
MinCapacity: 8.0
MaxCapacity: 27.5
StorageEncrypted: true
EnableCloudwatchLogsExports:
- error
- slowquery
- audit
WriterInstance:
Type: AWS::RDS::DBInstance
Properties:
DBInstanceClass: db.serverless
Engine: aurora-mysql
DBClusterIdentifier: !Ref DBCluster
```
9. CDK snippet (TypeScript):
```typescript
const parameterGroup = new rds.ParameterGroup(this, "PG", {
engine: rds.DatabaseClusterEngine.auroraMysql({ version: rds.AuroraMysqlEngineVersion.VER_3_08_0 }),
parameters: { "require_secure_transport": "ON" },
});
const cluster = new rds.DatabaseCluster(this, "Cluster", {
engine: rds.DatabaseClusterEngine.auroraMysql({ version: rds.AuroraMysqlEngineVersion.VER_3_08_0 }),
parameterGroup,
serverlessV2MinCapacity: 8.0,
serverlessV2MaxCapacity: 27.5,
writer: rds.ClusterInstance.serverlessV2("Writer"),
storageEncrypted: true,
cloudwatchLogsExports: ["error", "slowquery", "audit"],
});
```
10. Testing: snapshot-restore to a test cluster first; run full load tests at peak TPS; observe `ServerlessDatabaseCapacity` CloudWatch metric to verify ACU actually scales with load.
## Scale-to-zero / auto-pause rules
Aurora serverless auto-pause requires `MinCapacity: 0` and is **incompatible** with: RDS Proxy, binary logging (binlog) enabled, Global Database primary, and Zero-ETL integrations. If the user's workload has any of these, you MUST warn them that scale-to-zero cannot be enabled, and instead recommend a non-zero `MinCapacity` (e.g. 0.5 for dev/test, ≥1.0 for prod). In a multi-AZ cluster, auto-pause still works: the writer and any reader instances with failover priority 0 or 1 pause and resume together (their capacity is tied to the writer), while reader instances with failover priority 2-15 can pause independently. So a reader configured with priority 0/1 will not pause unless the writer also pauses — but the cluster as a whole can still scale to zero. See [concepts.md](serverless-advisory-concepts.md) for the complete compatibility matrix.
references/shared-foundation-security-considerations.md
# Security Considerations
The amazon-aurora skill creates and modifies Aurora resources when the user requests it, but blocks destructive operations (deletes, major upgrades, purchases). The agent MUST enforce the practices below.
## Table of Contents
1. [IAM Principles](#iam-principles)
2. [Credential Hygiene](#credential-hygiene)
3. [RDS Data API Warning](#rds-data-api-warning)
4. [Secure Defaults in Examples](#secure-defaults-in-examples)
5. [Output Handling](#output-handling)
## IAM Principles
The caller's IAM principal needs read and write permissions for RDS to create and modify clusters. Scope permissions to the minimum required actions.
Required permissions (by service):
| Service | Required actions |
|---|---|
| RDS | `rds:DescribeDBClusters`, `rds:DescribeDBInstances`, `rds:DescribeDBEngineVersions`, `rds:DescribeReservedDBInstancesOfferings` |
| CloudWatch | `cloudwatch:GetMetricStatistics`, `cloudwatch:ListMetrics` |
| Pricing | `pricing:GetProducts`, `pricing:DescribeServices` |
| Savings Plans | `savingsplans:DescribeSavingsPlansOfferings`, `savingsplans:DescribeSavingsPlansOfferingRates` |
Managed policies `AmazonRDSReadOnlyAccess` and `CloudWatchReadOnlyAccess` cover most of this; add Pricing and Savings Plans read actions via a scoped custom policy.
Do NOT use `AdministratorAccess` or `*:FullAccess` managed policies. Scope write permissions to the specific actions the skill uses: `rds:CreateDBCluster`, `rds:CreateDBInstance`, `rds:ModifyDBCluster`, `rds:ModifyDBInstance`, `rds:AddTagsToResource`, `rds:RemoveTagsFromResource`. For reads: `rds:Describe*`, `rds:List*`.
## Credential Hygiene
- Prefer short-lived credentials (IAM roles, `ada credentials update`, SSO) over long-lived IAM user keys.
- Do NOT create or store long-lived DB passwords from within the skill. If the user's Isengard credentials are expired, prompt them to refresh outside the skill.
- **IAM auth tokens are approved.** Calling `aws rds generate-db-auth-token` or `rds_client.generate_db_auth_token()` is explicitly safe — these produce short-lived (15-minute) tokens derived from the caller's IAM identity. They are not stored credentials. Use them when IAM database authentication is enabled on the cluster.
- Do NOT log or echo DB passwords or raw secret values. For RDS Data API precheck runs, reference secrets by their `secretArn` and let the service resolve them.
- For SSM Run Command prechecks, pass DB credentials via inline JSON parameters attached to the Run Command invocation — never via positional filesystem arguments.
## RDS Data API Warning
Enabling RDS Data API solely to run upgrade prechecks widens the cluster's connectivity surface. The Data API endpoint is HTTPS-reachable over the public AWS plane (authenticated with IAM), so it's safer than opening a new SG ingress rule, but it's still an additional attack surface.
- Warn the user before recommending they enable Data API for a one-off precheck run
- If the cluster is production and Data API is not already enabled, prefer the `user-runs-script` precheck method instead
- If Data API is enabled for the workflow, remind the user to disable it after prechecks if it wasn't previously in use
## Secure Defaults in Examples
Any CloudFormation, CDK, or AWS CLI snippet produced by this skill MUST use secure defaults:
- Cluster configuration: `StorageEncrypted: true` (and `KmsKeyId` if the user has a customer-managed key)
- TLS: cluster parameter group enables `require_secure_transport=ON`
- Security groups: scoped CIDR ranges or security-group references — NEVER `0.0.0.0/0` or `::/0`
- Public accessibility: NEVER use `--publicly-accessible`. If the user needs connectivity from outside the VPC, use the RDS Data API (HTTPS + IAM), an EC2 bastion with SSH tunnel, or VPN/Direct Connect into the VPC.
- Parameter groups: do NOT disable `general_log`, `slow_query_log`, the audit log (`server_audit_logging`), or other audit/logging parameters "for convenience"
- Logging & monitoring: recommend enabling **CloudTrail** so Aurora control-plane API activity (create / modify / delete / failover) is recorded, and **CloudWatch alarms** on security-relevant metrics such as `LoginFailures` and `DatabaseConnections`. CloudWatch log exports (`error`, `slowquery`, `audit`) give query-level visibility but do not cover API-level activity — CloudTrail does.
- Resource names: no `prod`, `production`, or `PROD` as example/default values — those get copy-pasted into production accidentally
## Output Handling
- Cost numbers, instance types, and cluster IDs are not sensitive on their own, but combined with account ID they reveal environment topology. When presenting results, don't unnecessarily include the account ID.
- If a workflow surfaces a secret ARN, show only the ARN, never attempt to resolve it.
- Upgrade precheck findings may include schema names, table names, or query text from the user's database. If the output is going to be shared (posted to a ticket, shared in chat), warn the user to review for sensitive identifiers before sharing.
## References
- [Security in Amazon Aurora](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/UsingWithRDS.html)
- [AWS Well-Architected Framework — Security Pillar](https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/welcome.html)
- [IAM database authentication for Aurora](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/UsingWithRDS.IAMDBAuth.html)
- [Using SSL/TLS with Aurora MySQL](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.Security.html)
references/upgrade-planning-documentation-links.md
# Aurora MySQL Upgrade Documentation Links
## Version Information
- Aurora MySQL LTS: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.Update.SpecialVersions.html
- Aurora MySQL Release Notes: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraMySQLReleaseNotes/AuroraMySQL.Updates.30Updates.html
- Aurora MySQL Release Calendar: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraMySQLReleaseNotes/AuroraMySQL.release-calendars.html
## Aurora MySQL Upgrade Blogs
- Upgrade to Aurora MySQL version 3: https://aws.amazon.com/blogs/database/upgrade-to-amazon-aurora-mysql-version-3-with-mysql-8-0-compatibility/
- v2 to v3 upgrade checklist Part 1: https://aws.amazon.com/blogs/database/amazon-aurora-mysql-version-2-with-mysql-5-7-compatibility-to-version-3-with-mysql-8-0-compatibility-upgrade-checklist-part-1/
- v2 to v3 upgrade checklist Part 2: https://aws.amazon.com/blogs/database/amazon-aurora-mysql-version-2-with-mysql-5-7-compatibility-to-version-3-with-mysql-8-0-compatibility-upgrade-checklist-part-2/
- Major version upgrades with minimum downtime: https://aws.amazon.com/blogs/database/performing-major-version-upgrades-for-amazon-aurora-mysql-with-minimum-downtime/
## Aurora MySQL AWS Documentation
- Upgrade prechecks: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.upgrade-prechecks.html
- Precheck descriptions: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.upgrade-prechecks.descriptions.html
- Blue/Green Deployments: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/blue-green-deployments.html
## Oracle MySQL 8.0 Documentation (behavior changes)
- What's New in MySQL 8.0: https://dev.mysql.com/doc/refman/8.0/en/mysql-nutshell.html
- MySQL 8.0 Release Notes: https://dev.mysql.com/doc/relnotes/mysql/8.0/en/
- Upgrading to MySQL 8.0: https://dev.mysql.com/doc/refman/8.0/en/upgrading.html
- MySQL 8.0 Reserved Keywords: https://dev.mysql.com/doc/refman/8.0/en/keywords.html
- MySQL 8.0 Upgrade Best Practices: https://dev.mysql.com/doc/refman/8.0/en/upgrade-best-practices.html
references/upgrade-planning-instructions.md
# Aurora Upgrade Advisor Workflow
Guide customers through Aurora MySQL major and minor version upgrades. Identifies the cluster, recommends target versions (latest vs LTS), runs live prechecks, flags query-plan regressions, and surfaces pre- and post-upgrade checklists. Major version upgrades are blocked — see SKILL.md Safety guidance. This reference helps plan the upgrade.
Execute commands using available tools from the AWS MCP server when connected (sandboxed execution, audit logging, observability). Fall back to the AWS CLI or shell when the MCP server is not available.
## When This Applies
User mentions: upgrade Aurora cluster, what version should I upgrade to, pre-upgrade checklist, post-upgrade steps, Aurora LTS, upgrade prechecks, Aurora MySQL upgrade, or major/minor version upgrade.
## Two response modes
**Mode A — Advisory (no cluster named):** User asks a general question like "what version should I upgrade to?" or "what's the LTS version?" without specifying a cluster. **Skip directly to LTS recommendation** (see "Mode A workflow" below). Do NOT ask for cluster ID and region first — recommend the LTS version with rationale, then offer to run the live workflow if they want a cluster-specific assessment.
**Mode B — Cluster-specific (cluster named):** User names a cluster identifier or asks you to plan an upgrade for a specific cluster. Run the full workflow (Tasks 1–8) with live AWS calls. Tasks are split across:
- [mode-b-discovery.md](upgrade-planning-mode-b-discovery.md) — Tasks 1–4: permissions, no-fabrication guard, acquire parameters, identify the cluster, determine upgrade targets.
- [lts-recommendation.md](upgrade-planning-lts-recommendation.md) — Task 5: recommend two options (LTS vs latest), with the authoritative current-LTS table and trade-offs.
- [mode-b-prechecks-checklists.md](upgrade-planning-mode-b-prechecks-checklists.md) — Tasks 6–8: live database prechecks, query-load analysis, pre/post-upgrade checklists and engine-specific blockers.
When the user reports a **completed** upgrade and asks what to check now, route to [post-upgrade-validation.md](upgrade-planning-post-upgrade-validation.md) (must-surface Aurora items + immediate cluster-state checks) and [post-upgrade-detail.md](upgrade-planning-post-upgrade-detail.md) (statistics refresh, extension updates, plan verification, parameter-group-family migration, snapshot rollback window, monitoring window).
## Mode A workflow (advisory, ~3-4 paragraphs)
When the user asks "what version should I upgrade to?" with their current version (e.g., "I'm on 3.04") but no cluster ID:
1. **Lead with LTS recommendation.** State the designated Aurora MySQL LTS version (Aurora MySQL 3.10 LTS) and frame it as the recommended target. Be direct — don't ask for more info first.
2. **Explain LTS rationale:** longer support window (~3 years of critical fixes), fewer forced upgrade cycles, suitable when stability matters more than new features.
3. **Mention the latest non-LTS option** as the alternative for users wanting newer features, but make clear LTS is the default recommendation.
4. **State the upgrade path:** for major version jumps from old versions (e.g., Aurora MySQL 2.x → 3.x, i.e. MySQL 5.7 → 8.0-compatible), the upgrade may require an intermediate hop or Blue/Green deployment. Direct major-version upgrades require prechecks, a maintenance window, and a rollback plan.
5. **Offer the cluster-specific workflow** as a follow-up: "If you share your cluster ID and region, I can pull the exact valid upgrade targets, run prechecks, and produce a pre/post-upgrade checklist."
Mode A does NOT need cluster identifier, region, or live AWS calls. It is general guidance, version-independent. For the authoritative current-LTS table and the LTS/latest trade-offs, see [lts-recommendation.md](upgrade-planning-lts-recommendation.md).
## Troubleshooting
**Cluster not found.** Check region and cluster identifier. For Global Databases, use `describe-global-clusters` with the global cluster identifier.
**Engine version shows `-limitless`.** Not applicable to Aurora MySQL — Aurora Limitless is an Aurora PostgreSQL-only capability. If you are actually working with an Aurora PostgreSQL cluster, use the `amazon-aurora-postgresql` skill.
**Precheck queries time out via SSM.** Increase the SSM timeout, or switch to RDS Data API if enabled. Large schemas can take minutes for `information_schema` queries.
**RDS Proxy compatibility unclear.** Check target version release notes. If unclear, test on a snapshot-restored clone with the proxy attached before production.
**User wants to roll back after a successful upgrade.** Rollback requires snapshot restore — no in-place downgrade. If the cluster is functioning but has a regression, debug it rather than roll back. See the post-upgrade checklist for regression-hunting steps.
## Deep-Dive References
- [mode-b-discovery.md](upgrade-planning-mode-b-discovery.md), [lts-recommendation.md](upgrade-planning-lts-recommendation.md), [mode-b-prechecks-checklists.md](upgrade-planning-mode-b-prechecks-checklists.md) — the Mode B cluster-specific workflow (Tasks 1–8)
- [post-upgrade-validation.md](upgrade-planning-post-upgrade-validation.md), [post-upgrade-detail.md](upgrade-planning-post-upgrade-detail.md) — post-upgrade validation must-surface items and detailed procedures
- [prechecks-mysql.md](upgrade-planning-prechecks-mysql.md) — live precheck SQL
- [query-load-mysql.md](upgrade-planning-query-load-mysql.md) — regression detection via EXPLAIN
- [pre-checklist.md](upgrade-planning-pre-checklist.md), [post-checklist.md](upgrade-planning-post-checklist.md) — actionable checklists
- [documentation-links.md](upgrade-planning-documentation-links.md) — authoritative AWS documentation pointers
references/upgrade-planning-lts-recommendation.md
# Recommend Two Options — LTS vs Latest (Task 5)
Part of the Mode B workflow (see [mode-b-discovery.md](upgrade-planning-mode-b-discovery.md) for Tasks 1–4). Also used by Mode A advisory answers.
Always present both:
- **Latest version**: highest minor within the newest supported major. More features and performance, but shorter support window before the next required upgrade.
- **LTS version**: Aurora MySQL 3.10 (a designated LTS minor). Extended support window (~3 years), critical fixes only, fewer required upgrade cycles.
## Designated LTS versions
This table is authoritative for "what's the LTS version right now" questions when live AWS is unreachable. AWS designates an LTS release **per supported major version simultaneously** — there is no single engine-wide LTS value. The correct LTS minor is whatever `describe-db-engine-versions` / the AWS LTS page lists for the major the customer targets. You MUST answer "what's the current LTS version" per the major the customer is on (the LTS minor depends on the major), not as a single engine-wide answer, and you MUST NOT list every minor version as if each were LTS. These move over time — verify via `describe-db-engine-versions`.
| Engine | Designated LTS (one per major) | Non-LTS (also supported) | Aurora's LTS commitment |
|---|---|---|---|
| Aurora MySQL | **3.10.\*** and **3.04.\*** (designated LTS minors) | 8.4.x (latest major, compatible with community MySQL 8.4 LTS — verify available minors via `describe-db-engine-versions`), 3.11+/3.12 (MySQL 8.0-compatible, prior major; non-LTS) | Minimum ~3 years of Aurora-extended support on each LTS minor, with only critical / security patches. |
**Important clarifications (address these explicitly when the user asks about LTS):**
1. **LTS is not a separate MAJOR version**. It's a specific MINOR release within a supported major that Aurora designates as "Long-Term Support." For Aurora MySQL, 3.10 is an LTS minor within the version-3 (MySQL-8.0-compatible) major; other 3.y minors are released on the regular cadence but the designated LTS minor receives only critical fixes and is supported for ~3 years. AWS designates one LTS minor per supported major simultaneously (3.10 and 3.04 are both designated LTS minors).
2. **Older versions are NOT LTS just because they're older — but a major can have more than one designated LTS minor.** Do not treat any old minor as LTS. For Aurora MySQL the designated LTS minors are 3.10 and 3.04; other 3.x minors (e.g. 3.11/3.12) are non-LTS, and the real lifecycle caveat for older minors is end-of-standard-support, not LTS status.
3. **LTS is opt-in via parameter choice at upgrade time** — you're not automatically on LTS. When upgrading, you choose an LTS minor (e.g. 3.10) vs. a latest non-LTS minor.
4. **Why pick LTS over latest:**
- **Longer stability window**: ~3 years of support vs. ~1 year for a non-LTS minor.
- **Patch cadence is predictable**: only critical fixes land; no quarterly feature-or-behaviour changes that force re-testing.
- **Fewer required upgrade cycles**: reduce operational overhead for teams that can't test upgrades quarterly.
- **Regulatory alignment**: auditors often expect 1–3 year rolling platform refresh cycles; LTS fits cleanly.
5. **Why pick latest over LTS:**
- **Access to new features**: window functions and CTEs (MySQL 8.0), instant DDL improvements, JSON enhancements, newer SQL syntax, better optimizer/parallelism.
- **Performance improvements**: newer versions are typically 5–15% faster on analytic workloads.
- **Security modernization**: deprecated crypto removed sooner.
6. **Trade-offs you MUST surface when the user asks:**
- On LTS you must **disable automatic minor version upgrades**, or Aurora will move you off the LTS minor onto the latest non-LTS during the next maintenance window. Set `AutoMinorVersionUpgrade: false` on the cluster and its instances.
- Staying on LTS means you will not get non-critical bug fixes or new features until you deliberately upgrade to a later LTS or the current latest.
- LTS versions also get upgraded eventually — when Aurora designates a new LTS minor for a newer major (the current LTS minors change over time; verify via `describe-db-engine-versions`), you'll need a major-version upgrade cycle then too.
**Constraints:**
- You MUST present both options with trade-offs, not just one
- You MUST NOT recommend LTS unconditionally — frame it as a choice based on risk tolerance and upgrade-cadence capacity
- You MUST NOT list every minor version as if it were LTS. AWS designates one LTS minor per supported major, so the answer depends on the major the customer targets.
- When the user asks "what's the LTS version right now", you MUST cite the table above and the specific LTS minor for the relevant major (e.g. "the current Aurora MySQL LTS minors are 3.10 and 3.04"), not a long list of minor versions.
references/upgrade-planning-mode-b-discovery.md
# Mode B — Discovery (Tasks 1–4)
Cluster-specific workflow. Run these with live AWS calls after the user names a cluster. Continue to [lts-recommendation.md](upgrade-planning-lts-recommendation.md) (Task 5) and [mode-b-prechecks-checklists.md](upgrade-planning-mode-b-prechecks-checklists.md) (Tasks 6–8).
## 1. Check Permissions
**Constraints:**
- You MUST confirm AWS credentials allow `rds:DescribeDBClusters`, `rds:DescribeDBEngineVersions`, and `rds:DescribeDBInstances` before starting
- You MUST pause if credentials are missing — tasks 3–6 require live AWS access
- You MAY proceed with checklist-only tasks (7) without AWS access
## 1a. No Fabrication When Live AWS is Unreachable
If credentials are missing, the cluster isn't found, or any `describe-*` call fails, you MUST report the exact failure to the user and stop that step. There is no "demonstration mode" for this workflow — fabricating example `describe-db-clusters` or `describe-db-engine-versions` output and then recommending targets derived from it produces a plausible-looking answer with no factual basis, and users have acted on those fabricated answers.
**Constraints:**
- You MUST NOT invent values for `EngineVersion`, `Engine`, `DBClusterParameterGroup`, `ValidUpgradeTarget`, instance class, `Status`, or any other field that a `describe-*` call would return, because the user will reasonably assume those values came from their cluster
- You MUST NOT describe such invented output as "expected output shape" or "representative" and then use it as the basis for a recommendation, because that is the exact pattern that misled users in past runs
- You MUST NOT claim in a completion summary that you "used `describe-db-engine-versions` as source of truth" when you did not execute it — self-reporting contradicting the transcript is worse than the original fabrication
- When the cluster cannot be queried, you MUST either (a) show the exact commands the user should run and ask them to paste the JSON output, or (b) ask the user to supply the current engine + version so you can advise on upgrade paths from general knowledge, clearly labeled as non-authoritative
- You MAY present LTS/latest trade-offs and checklists (tasks 5, 8) from general knowledge even without live data, because those are genuinely version-independent guidance
- You MUST NOT present a specific target version (e.g., "upgrade to 3.08") as a recommendation unless `describe-db-engine-versions` actually returned it, because valid targets depend on the current version and change over time
## 2. Acquire Target Parameters
Required: **cluster identifier** (string) and **region** (string, AWS region code, default `us-east-1`).
Optional: **target version** (string, e.g. `3.08`; omit to see all options), **connection method** for live prechecks (one of `ssm`, `data-api`, `direct`, `user-runs-script`).
**Constraints for parameter acquisition:**
- You MUST ask for cluster identifier and region upfront in a single prompt
- You MUST confirm the captured values before running discovery
- You SHOULD offer the four connection methods as choices when ready for prechecks — do not pick for them
## 3. Identify the Cluster
```bash
aws rds describe-db-clusters --db-cluster-identifier <cluster_id> --region <region> \
--query "DBClusters[0].{Engine:Engine,EngineVersion:EngineVersion,Status:Status,EngineMode:EngineMode,DeletionProtection:DeletionProtection,StorageEncrypted:StorageEncrypted,DBClusterParameterGroup:DBClusterParameterGroup}"
```
If not found, check for Global Database with `describe-global-clusters`. Get instance class with `describe-db-instances --filters "Name=db-cluster-id,Values=<cluster_id>"`.
**Constraints:**
- You MUST detect and handle clusters with no DB instances (empty `DBClusterMembers`). For Aurora MySQL these are usually paused clusters or mid-migration states; confirm the cluster's state with the user before proceeding rather than assuming it is upgrade-ready
## 4. Determine Upgrade Targets
```bash
aws rds describe-db-engine-versions --engine aurora-mysql --engine-version <current_version> --region <region> \
--query "DBEngineVersions[0].ValidUpgradeTarget[*].{EngineVersion:EngineVersion,IsMajorVersionUpgrade:IsMajorVersionUpgrade}"
```
**Constraints:**
- You MUST verify version info via `describe-db-engine-versions` rather than hard-coding because the LTS version and valid targets change over time
references/upgrade-planning-mode-b-prechecks-checklists.md
# Mode B — Prechecks & Checklists (Tasks 6–8)
Continues the Mode B workflow from [mode-b-discovery.md](upgrade-planning-mode-b-discovery.md) (Tasks 1–4) and [lts-recommendation.md](upgrade-planning-lts-recommendation.md) (Task 5).
## 6. Live Database Prechecks
Ask the customer how to connect:
1. **SSM Run Command** — requires an EC2 instance ID in the same VPC and DB credentials
2. **RDS Data API** — if enabled, no extra infrastructure needed
3. **Direct connection** — if publicly accessible or a tunnel is set up
4. **User runs the script** — you generate the SQL, the user pastes results back
Then run the MySQL precheck queries from [prechecks-mysql.md](upgrade-planning-prechecks-mysql.md).
**Constraints:**
- You MUST ask the user to choose the connection method — do not pick for them
- You MUST NOT create, access, or store AWS credentials or DB passwords directly. Use inline JSON payloads for SSM, user-supplied secret ARNs for Data API, or pre-configured tunnels for direct
- You MUST categorize every finding with one of: 🔴 Critical (blocks upgrade), 🟡 Warning (behavior change), 🟢 Clean
- You MUST generate a recommended parameter group configuration based on findings rather than returning raw query output
## 7. Query Load Analysis (Optional)
After schema prechecks, offer to analyze top queries. Use [query-load-mysql.md](upgrade-planning-query-load-mysql.md).
**Constraints:**
- You MUST run EXPLAIN in the MySQL format: `EXPLAIN FORMAT=JSON`
- You MUST categorize each query's upgrade risk with the same three-color system as task 6
- You SHOULD present findings in a compact table (summary, plan issue, upgrade impact, action) rather than raw EXPLAIN output
## 8. Pre- and Post-Upgrade Checklists
Provide:
- Pre-upgrade steps from [pre-checklist.md](upgrade-planning-pre-checklist.md)
- Post-upgrade validation from [post-checklist.md](upgrade-planning-post-checklist.md)
You MUST also surface the engine-specific upgrade **blockers and required cleanup items** directly in your response — do not leave them buried in the precheck files the user hasn't opened. These are the items that most commonly cause upgrade failures or silent breakage.
**For Aurora MySQL, surface at minimum** (from [prechecks-mysql.md](upgrade-planning-prechecks-mysql.md)):
- 🔴 **Reserved keywords** added in 8.0 used as unquoted identifiers — blocks queries post-upgrade.
- 🔴 **Removed data types / SQL features** (e.g., `utf8mb3` as default, `PRE_5_6_26_UTF8_JSON` flag, deprecated spatial functions).
- 🟡 **sql_mode and default charset/collation changes** — `utf8mb4_0900_ai_ci` becomes the default; application assumptions about collation ordering will shift.
- 🟡 **Query cache removal** (5.7 → 8.0) — if the cluster relied on query cache, expect CPU/latency delta after upgrade.
- 🟡 **Authentication plugin changes** — MySQL 8.0 defaults to `caching_sha2_password`; older clients may need `mysql_native_password`.
**Constraints:**
- You MUST include engine-specific sections of each checklist, not just common steps
- You MUST surface the engine-specific blockers inline in your response using the 🔴/🟡/🟢 taxonomy — listing only the file path is insufficient because users don't follow those references unprompted
- You MUST explicitly address items that don't apply to this upgrade path (e.g., state "Query cache removal — not applicable when upgrading Aurora MySQL 8.0 → 8.4, only relevant from a 5.7 source") rather than silently omitting them; otherwise the user can't tell whether you checked or forgot
- You MUST NOT execute any `modify-db-cluster --engine-version` command because this workflow is planning-only and production upgrades must go through the customer's change process
- You MUST recommend testing on a snapshot-restored cluster before production upgrade
- You SHOULD surface relevant documentation from [documentation-links.md](upgrade-planning-documentation-links.md)
For post-upgrade validation when the user reports a completed upgrade, see [post-upgrade-validation.md](upgrade-planning-post-upgrade-validation.md).
references/upgrade-planning-post-checklist.md
# Post-Upgrade Checklist
## Common Steps
1. **Verify upgrade completed**
```bash
aws rds describe-db-clusters --db-cluster-identifier {cluster} \
--query "DBClusters[0].{Engine:Engine,EngineVersion:EngineVersion,Status:Status}" \
--output json --region {region}
```
2. **Preserve the rollback window — do NOT delete pre-upgrade snapshots immediately.** Major version upgrades are **one-way** in-place. Rollback requires restoring from a snapshot or PITR, and both restore the **old** major version:
- Any **pre-upgrade manual snapshot** restores to the engine version it was taken on (e.g., an Aurora MySQL 3.04 snapshot restores to 3.04 — not to a post-upgrade 3.10).
- **PITR to any time before the upgrade completed** restores the pre-upgrade major version, not the new one.
- After the upgrade, Aurora cannot restore backward-in-time into the new major version; that timeline starts at the upgrade's completion.
Keep the pre-upgrade manual snapshot for **at least 7–14 days of stable production traffic** (longer for regulated workloads) before deleting it. Deleting it early forecloses the cheapest rollback path. Document the snapshot identifier and retain-until date in your change record.
3. **Check performance discrepancies** — Compare CloudWatch metrics against baseline: CPUUtilization, DatabaseConnections, ReadLatency, WriteLatency, FreeableMemory, BufferCacheHitRatio, DMLLatency, SelectLatency. Use Performance Insights to compare database load.
4. **Compare EXPLAIN plans** for critical queries. Look for: different join strategies, missing index usage, full table scans.
- `EXPLAIN FORMAT=JSON SELECT ...;` (or `EXPLAIN ANALYZE` on MySQL 8.0+ for actual row counts)
5. **Monitor CloudWatch 24-72 hours** — Watch: CPUUtilization, FreeableMemory, DatabaseConnections, ReadLatency, WriteLatency, AuroraReplicaLag, Deadlocks, LoginFailures.
6. **Validate application connectivity** — connections, pooling, SSL/TLS.
7. **Verify parameter group** applied correctly:
```bash
aws rds describe-db-cluster-parameters --db-cluster-parameter-group-name {new_pg} \
--query "Parameters[?Source=='user'].{Name:ParameterName,Value:ParameterValue}" \
--output table --region {region}
```
8. **Update statistics** — run `ANALYZE TABLE` on hot tables to rebuild optimizer statistics for the new version.
9. **Check error logs**
```bash
aws rds describe-events --source-identifier {cluster} --source-type db-cluster --duration 1440 --region {region}
```
## Aurora MySQL-Specific
1. **Verify auth plugin compatibility** — `SELECT user, host, plugin FROM mysql.user;` Check if apps need mysql_native_password.
2. **Check GROUP BY sorting** — 8.0 no longer implicitly sorts. Apps relying on this need explicit ORDER BY.
3. **Validate stored procedures** — run critical routines, check for deprecated syntax.
4. **Verify query cache removal impact** — if query cache was enabled, monitor for increased CPU/latency. Consider ElastiCache if hit ratio was high.
references/upgrade-planning-post-upgrade-detail.md
# Post-Upgrade Validation — detailed procedures
Companion to [post-upgrade-validation.md](upgrade-planning-post-upgrade-validation.md). These are the detailed Aurora MySQL post-upgrade procedures referenced there. Surface them alongside the four Aurora-specific items.
## Statistics refresh (CRITICAL)
A major-version upgrade does NOT recompute table statistics, and stale statistics against the new optimizer are a top cause of post-upgrade plan regressions. You MUST refresh statistics before trusting query plans post-upgrade:
- Run `ANALYZE TABLE` on hot tables to rebuild index/column statistics for the new optimizer.
```sql
ANALYZE TABLE critical_table_1, critical_table_2;
```
- `OPTIMIZE TABLE` is more aggressive (it rebuilds the table and reclaims fragmentation) but takes a table lock — avoid on production unless you specifically need to defragment, and run it in a maintenance window.
## Plugin / component checks (Aurora MySQL)
Aurora MySQL has no PostgreSQL-style extension catalog to update post-upgrade, but verify any server components or audit plugins are still loaded and compatible with the new major version:
```sql
-- confirm expected plugins are ACTIVE (e.g. audit/auth plugins)
SELECT plugin_name, plugin_status, plugin_type FROM information_schema.plugins
WHERE plugin_status <> 'ACTIVE';
```
If you used `mysql_native_password` auth, confirm application drivers work with the 8.0 default `caching_sha2_password` (or that the plugin is still available).
## Query plan verification
Optimiser changes across major versions are one of the top causes of post-upgrade regression. For each critical query that was in the "hot queries" set before the upgrade, capture a fresh plan and compare:
Use `EXPLAIN FORMAT=JSON` (estimated plan) or `EXPLAIN ANALYZE` (MySQL 8.0+; runs the query and reports actual vs estimated row counts and timing):
```sql
EXPLAIN FORMAT=JSON SELECT ... FROM hot_table WHERE ...;
EXPLAIN ANALYZE SELECT ... FROM hot_table WHERE ...;
```
Look for:
- Different join ordering or join algorithm (nested-loop ↔ hash join; MySQL 8.0.18+ adds hash joins).
- Index usage changes (previously used index now not chosen).
- Large gaps between estimated and actual rows (selectivity estimates degraded — refresh stats with `ANALYZE TABLE`).
## Aurora parameter group family migration (commonly missed)
**Aurora cluster parameter groups are pinned to a specific major version family** (e.g. `aurora-mysql5.7`, `aurora-mysql8.0`, `aurora-mysql8.4`). The upgrade process creates a **new** parameter group in the target family OR requires you to assign one — you cannot reuse an `aurora-mysql5.7` parameter group on an 8.0 cluster.
Verify the cluster is actually using a target-family parameter group:
```bash
aws rds describe-db-clusters --db-cluster-identifier <cluster> \
--query "DBClusters[0].{PG:DBClusterParameterGroup}" --region <region>
# Inspect custom parameter values
aws rds describe-db-cluster-parameters \
--db-cluster-parameter-group-name <new-pg> \
--query "Parameters[?Source=='user'].{Name:ParameterName,Value:ParameterValue}" \
--output table --region <region>
```
**Risk**: if the pre-upgrade cluster had custom parameters (e.g. `innodb_buffer_pool_size`, `max_connections`, `innodb_log_file_size`, custom logging settings), those MUST be re-applied to the new-family parameter group — they are NOT carried across automatically. Mis-applied parameter groups are the second-largest source of post-upgrade regressions after optimiser changes.
## Pre-upgrade snapshot — rollback window (CRITICAL)
If you took a **pre-upgrade manual snapshot** (you should have — it's a pre-upgrade-checklist item), note that:
- A snapshot taken on the **old** major version restores to the **old** major version — NOT to the post-upgrade new version. A 3.04 snapshot → 3.04 restore. You cannot "upgrade by restoring from a snapshot."
- This snapshot is your rollback path for the first 7–14 days post-upgrade. Do NOT delete it until you've confirmed the new version is stable under full production load. 7–14 days of stable production is the industry norm; longer for regulated workloads.
- If you need to rollback, you restore the pre-upgrade snapshot into a new cluster, then cut traffic back over. There is no in-place downgrade.
## Monitoring window (24–72 hours)
Watch these CloudWatch metrics for the first 24–72 hours and compare to pre-upgrade baselines:
- **`CPUUtilization`** — a 5–15% change is normal; > 25% indicates a plan regression.
- **`DatabaseConnections`** — should be stable; sudden rise can mean connection-pool re-auth loops on engine changes.
- **`ReadLatency`, `WriteLatency`, `DMLLatency`, `SelectLatency`** — p95 should return to baseline within 2 hours; sustained elevation indicates query-plan issues.
- **`FreeableMemory`** — especially important if the cluster uses a custom `innodb_buffer_pool_size`; freezing at a different level indicates a parameter-group-family migration issue.
- **`BufferCacheHitRatio`** (the Aurora MySQL buffer-pool hit ratio) — should be ≥95% for OLTP; drop below 90% means statistics or cache warmup issue.
- **`AuroraReplicaLag`, `AuroraReplicaLagMaximum`** — as above, should settle below 100 ms for readers.
- **`Deadlocks`, `LoginFailures`** — both should be near pre-upgrade baseline; a spike can signal an authentication-plugin change (`caching_sha2_password` default in MySQL 8.0) or a reserved-word conflict.
## What NOT to do post-upgrade
- **Do NOT suggest a rollback as the first response to a minor issue.** The rollback path destroys the timeline and takes significant effort. Debug regressions on the new version first.
- **Do NOT delete the pre-upgrade snapshot in the first week**. That is the rollback path.
- **Do NOT run `modify-db-cluster --engine-version`** to downgrade — downgrades are not supported in-place. (Downgrades are not supported in-place by Aurora.)
- **Do NOT skip the `ANALYZE TABLE` pass** on hot tables even if the optimizer seems to be running fine. A one-time post-upgrade manual statistics refresh is insurance.
references/upgrade-planning-post-upgrade-validation.md
# Post-Upgrade Validation — must-surface items
When the user reports they just completed an upgrade and asks what to check now, you MUST surface **all of the items below** with Aurora-specific detail — do not leave them in the checklist file. They must appear by name in your reply. See [post-upgrade-detail.md](upgrade-planning-post-upgrade-detail.md) for the statistics-refresh, extension-update, plan-verification, parameter-group-family, snapshot-rollback, and monitoring-window procedures.
## Aurora-specific post-upgrade items (MUST appear by name in your reply — not just cited by reference)
A generic RDS-for-MySQL post-upgrade checklist misses Aurora-specific blockers and leaves the user exposed. These Aurora-specific items MUST appear in your reply:
1. **`SELECT aurora_version()`** — run on the writer AND on each reader. Aurora has an internal version distinct from the MySQL-engine version reported by `SELECT VERSION()`. Confirm both writer and readers report the expected Aurora internal version; a mismatch means the rolling-upgrade is incomplete on some readers.
2. **Aurora parameter-group family migration (e.g. `aurora-mysql5.7` → `aurora-mysql8.0`)** — **CRITICAL**: Aurora cluster parameter groups are pinned to a specific major-version family. **An `aurora-mysql5.7` family parameter group does NOT carry forward to an 8.0 cluster** — Aurora cannot attach a 5.7-family parameter group to an 8.0-family cluster. Post-upgrade, the cluster is using either: (a) the new-family default (`default.aurora-mysql8.0`), which is NOT your custom pre-upgrade settings, or (b) a new custom parameter group you created in the new family. **Any custom parameters you had set pre-upgrade (e.g. `innodb_buffer_pool_size`, `max_connections`, `innodb_log_file_size`, `slow_query_log`) MUST be manually re-applied to the new-family parameter group — they are NOT auto-migrated.** Verify with `aws rds describe-db-clusters` that the cluster is on the new-family parameter group, then compare the `user`-sourced parameters against your pre-upgrade notes. Mis-applied parameter-group family is the second-largest source of post-upgrade performance regressions (after optimiser changes).
3. **`AuroraReplicaLag`** CloudWatch metric — replica-to-writer replication lag, in milliseconds. Immediately post-upgrade readers may show 1–10 second lag while catching up; sustained > 100 ms for > 15 min indicates a problem. Also watch **`AuroraReplicaLagMaximum`** for the worst-case reader.
4. **Global Database secondary-cluster status** — if the cluster is part of an Aurora Global Database, the secondary cluster(s) in other regions are upgraded separately and MAY not be in sync. Use `aws rds describe-global-clusters` to confirm each member is on the target version. A secondary stuck at the pre-upgrade version means the cross-region replication is broken until you upgrade the secondary too.
These four items are in ADDITION to the generic items in [post-upgrade-detail.md](upgrade-planning-post-upgrade-detail.md) (statistics refresh via `ANALYZE TABLE`, plugin/component checks, `EXPLAIN`/`EXPLAIN ANALYZE` plan verification, pre-upgrade snapshot rollback window, CloudWatch monitoring). Omitting the four Aurora-specific items above leaves real gaps — include them.
## Immediate cluster-state checks
1. **Confirm the upgrade completed and the cluster is healthy.**
```bash
aws rds describe-db-clusters --db-cluster-identifier <cluster> \
--query "DBClusters[0].{Engine:Engine,EngineVersion:EngineVersion,Status:Status,ParameterGroup:DBClusterParameterGroup}" \
--region <region>
```
2. **Verify the Aurora engine-internal version matches the expected target** via SQL (NOT just the RDS API — they can differ mid-rollout):
```sql
SELECT aurora_version();
SELECT VERSION();
```
3. **Verify Aurora replicas have re-synced**. The critical CloudWatch metric is **`AuroraReplicaLag`** (milliseconds of replication lag between writer and each reader). Immediately after a rolling-upgrade, readers may show elevated lag (1–10 seconds) for a few minutes while they catch up. If lag stays > 100 ms for more than 15 minutes, something is wrong. Also check `AuroraReplicaLagMaximum` for the worst-case reader.
references/upgrade-planning-pre-checklist.md
# Pre-Upgrade Checklist
## Common Steps (Both Engines)
1. **Create test environment via snapshot restore**
```bash
aws rds create-db-cluster-snapshot --db-cluster-identifier {cluster} \
--db-cluster-snapshot-identifier {cluster}-pre-upgrade-snapshot --region {region}
aws rds restore-db-cluster-from-snapshot --db-cluster-identifier {cluster}-upgrade-test \
--snapshot-identifier {cluster}-pre-upgrade-snapshot \
--engine {engine} --engine-version {target_version} --region {region}
```
2. **Review and create new parameter group**
```bash
aws rds describe-db-cluster-parameters --db-cluster-parameter-group-name {current_pg} \
--query "Parameters[?Source=='user' && ParameterValue!=null].{Name:ParameterName,Value:ParameterValue}" \
--output table --region {region}
```
Create new group for target version family and apply custom parameters.
3. **Check pending maintenance actions**
```bash
aws rds describe-pending-maintenance-actions \
--resource-identifier arn:aws:rds:{region}:{account}:cluster:{cluster} --region {region}
```
4. **Capture baseline performance metrics** — CloudWatch: CPUUtilization, DatabaseConnections, ReadLatency, WriteLatency, FreeableMemory, BufferCacheHitRatio. Save EXPLAIN plans for critical queries.
5. **Consider Blue/Green Deployments** for minimal downtime. Ref: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/blue-green-deployments.html
6. **Plan maintenance window** — schedule during lowest traffic (use Performance Insights).
## Aurora MySQL-Specific
1. **Run upgrade prechecks** on test cluster first. Ref: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.upgrade-prechecks.html
2. **Check reserved keywords** — MySQL 8.0 added: CUME_DIST, DENSE_RANK, EMPTY, EXCEPT, FIRST_VALUE, GROUPING, GROUPS, JSON_TABLE, LAG, LAST_VALUE, LATERAL, LEAD, NTH_VALUE, NTILE, OF, OVER, PERCENT_RANK, RANK, RECURSIVE, ROW, ROWS, ROW_NUMBER, SYSTEM, WINDOW. Full list: https://dev.mysql.com/doc/refman/8.0/en/keywords.html
3. **Review MySQL 8.0 behavior changes** — Customer MUST review:
- What's New: https://dev.mysql.com/doc/refman/8.0/en/mysql-nutshell.html
- Release Notes: https://dev.mysql.com/doc/relnotes/mysql/8.0/en/
- Upgrade Guide: https://dev.mysql.com/doc/refman/8.0/en/upgrading.html
- Key changes: latin1→utf8mb4 default, GROUP BY no implicit sort, query cache removed, caching_sha2_password default, optimizer changes
4. **Check XA transactions**: `XA RECOVER;` — must commit/rollback before upgrade.
5. **Enable binary logging for Blue/Green** — associate the cluster with a *custom* DB cluster parameter group that has `binlog_format` turned ON (ROW recommended; STATEMENT/MIXED also work), then reboot the writer so it is in sync with the parameter group (otherwise Blue/Green creation fails). Also set a non-NULL binlog retention period. Note `binlog_format` is deprecated as of MySQL 8.0.34, so ROW is preferred for new setups. Ref: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/blue-green-deployments-creating.html
6. **Check deprecated features**: mysql_native_password, partitioned tables in shared tablespaces, old temporal columns.
references/upgrade-planning-prechecks-mysql.md
# Aurora MySQL Live Precheck Queries
Run these against the database to identify actual upgrade blockers and behavior changes.
## Connection Methods
### SSM Run Command with IAM Authentication (preferred)
IAM database authentication eliminates passwords entirely — the mysql client authenticates with a short-lived token generated from IAM credentials. Requires IAM auth enabled on the cluster and a database account configured for IAM auth.
```bash
aws ssm send-command --instance-ids {instance_id} --document-name "AWS-RunShellScript" \
--parameters 'commands=["TOKEN=$(aws rds generate-db-auth-token --hostname {endpoint} --port 3306 --region {region} --username {iam_db_user}) && mysql -h {endpoint} -u {iam_db_user} --port=3306 --ssl-ca=/tmp/global-bundle.pem --enable-cleartext-plugin --password=$TOKEN -e \"{query}\""]' \
--region {region} --output json --query "Command.CommandId"
```
The EC2 instance must have the RDS CA bundle (`global-bundle.pem`) available. Download it as a prior SSM command if needed: `curl -o /tmp/global-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem`
### SSM Run Command with Secrets Manager (fallback)
Use when IAM database authentication is not enabled on the cluster. Credentials are retrieved from Secrets Manager at runtime so passwords never appear in SSM parameters or CloudTrail logs. The password is passed via a temporary options file to avoid exposure in the process list.
```bash
aws ssm send-command --instance-ids {instance_id} --document-name "AWS-RunShellScript" \
--parameters 'commands=["SECRET=$(aws secretsmanager get-secret-value --secret-id {secret_arn} --query SecretString --output text --region {region}) && TMPFILE=$(mktemp) && chmod 600 $TMPFILE && printf \"[client]\\nuser=%s\\npassword=%s\\n\" \"$(echo $SECRET | jq -r .username)\" \"$(echo $SECRET | jq -r .password)\" > $TMPFILE && mysql --defaults-extra-file=$TMPFILE -h {endpoint} -e \"{query}\"; rm -f $TMPFILE"]' \
--region {region} --output json --query "Command.CommandId"
```
Retrieve results:
```bash
aws ssm get-command-invocation --command-id {id} --instance-id {instance_id} --region {region}
```
If mysql client not installed:
- Amazon Linux 2: `sudo yum install -y mysql`
- Amazon Linux 2023: `sudo dnf install -y mariadb105`
- Ubuntu: `sudo apt-get install -y mysql-client`
If connection times out (error 110), check security groups:
- Aurora SG must allow inbound from EC2 SG on port 3306
- Add rule: `aws ec2 authorize-security-group-ingress --group-id {aurora_sg} --protocol tcp --port 3306 --source-group {ec2_sg} --region {region}`
### RDS Data API
```bash
aws rds-data execute-statement --resource-arn {cluster_arn} --secret-arn {secret_arn} \
--database {db} --sql "{query}" --region {region}
```
## Precheck Queries
### 1. Reserved Keywords in Schema Objects
```sql
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS
WHERE UPPER(COLUMN_NAME) IN ('CUME_DIST','DENSE_RANK','EMPTY','EXCEPT','FIRST_VALUE','GROUPING','GROUPS','JSON_TABLE','LAG','LAST_VALUE','LATERAL','LEAD','NTH_VALUE','NTILE','OF','OVER','PERCENT_RANK','RANK','RECURSIVE','ROW','ROWS','ROW_NUMBER','SYSTEM','WINDOW')
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys');
SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.TABLES
WHERE UPPER(TABLE_NAME) IN ('CUME_DIST','DENSE_RANK','EMPTY','EXCEPT','FIRST_VALUE','GROUPING','GROUPS','JSON_TABLE','LAG','LAST_VALUE','LATERAL','LEAD','NTH_VALUE','NTILE','OF','OVER','PERCENT_RANK','RANK','RECURSIVE','ROW','ROWS','ROW_NUMBER','SYSTEM','WINDOW')
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys');
```
Flag: Any results = must quote with backticks or rename.
### 2. Authentication Plugins
```sql
SELECT user, host, plugin FROM mysql.user;
```
Flag: `mysql_native_password` still works but deprecated. `sha256_password` replaced by `caching_sha2_password`.
### 3. XA Transactions
```sql
XA RECOVER;
```
Flag: 🔴 Any results BLOCK the upgrade. Must commit or rollback first.
### 4. Server Character Set and Collation
```sql
SELECT @@character_set_server, @@collation_server, @@character_set_database, @@collation_database;
```
Flag: If `latin1` — MySQL 8.0 defaults to `utf8mb4`. New objects will differ unless parameter group preserves it.
### 5. Schema-Level Character Sets
```sql
SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA;
```
### 6. Critical Global Variables
```sql
SHOW GLOBAL VARIABLES WHERE Variable_name IN (
'lower_case_table_names','explicit_defaults_for_timestamp','show_compatibility_56',
'query_cache_type','query_cache_size','default_authentication_plugin',
'innodb_strict_mode','sql_mode','optimizer_switch','log_warnings',
'innodb_file_format','innodb_large_prefix'
);
```
Interpretation:
| Variable | Issue if... | Impact |
|----------|------------|--------|
| `query_cache_type=ON` | 🔴 Query cache REMOVED in 8.0 | Performance regression likely |
| `query_cache_size>0` | Memory was allocated to cache | Will be freed after upgrade |
| `sql_mode=''` (empty) | 🟡 8.0 defaults to strict mode | Apps may break unless preserved |
| `show_compatibility_56=ON` | 🔴 REMOVED in 8.0 | Monitoring querying INFORMATION_SCHEMA.GLOBAL_STATUS breaks |
| `log_warnings` | 🟡 REMOVED in 8.0 | Replace with `log_error_verbosity` |
| `innodb_strict_mode=OFF` | 🟡 8.0 defaults to ON | Preserve in parameter group |
### 7. Stored Procedures and Functions
```sql
SELECT ROUTINE_SCHEMA, ROUTINE_NAME, ROUTINE_TYPE, DEFINER
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys');
```
Flag: Need syntax review for deprecated constructs.
### 8. Triggers and Events with Null Definers
```sql
SELECT TRIGGER_SCHEMA, TRIGGER_NAME, DEFINER FROM information_schema.TRIGGERS
WHERE DEFINER = '' OR DEFINER IS NULL;
SELECT EVENT_SCHEMA, EVENT_NAME, DEFINER FROM information_schema.EVENTS
WHERE DEFINER = '' OR DEFINER IS NULL;
```
Flag: 🔴 Null definers cause precheck failures.
### 9. Partitioned Tables
```sql
SELECT TABLE_SCHEMA, TABLE_NAME, PARTITION_METHOD FROM information_schema.PARTITIONS
WHERE PARTITION_METHOD IS NOT NULL
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys');
```
Flag: Non-native partitioning removed in 8.0.
### 10. Table Engines and Row Formats
```sql
SELECT TABLE_SCHEMA, TABLE_NAME, ENGINE, TABLE_COLLATION, ROW_FORMAT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')
AND TABLE_TYPE='BASE TABLE';
```
Flag: Non-InnoDB tables, COMPACT row format (should be DYNAMIC).
### 11. Foreign Keys, Views, Grants
```sql
SELECT TABLE_SCHEMA, TABLE_NAME, CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
WHERE REFERENCED_TABLE_NAME IS NOT NULL
AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys');
SELECT TABLE_SCHEMA, TABLE_NAME, DEFINER, SECURITY_TYPE FROM information_schema.VIEWS
WHERE TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys');
SELECT user, host, Super_priv, Grant_priv FROM mysql.user
WHERE user NOT IN ('rdsadmin','mysql.sys','rdsrepladmin');
```
### 12. Stale Table Statistics
Query `mysql.innodb_table_stats.last_update` (when InnoDB last **recalculated stats**),
not `information_schema.TABLES.UPDATE_TIME` (last DML — doesn't reflect stats freshness):
```sql
SELECT database_name, table_name, last_update,
DATEDIFF(NOW(), last_update) AS days_since_stats_update
FROM mysql.innodb_table_stats
WHERE database_name NOT IN ('mysql','sys')
AND (last_update IS NULL OR DATEDIFF(NOW(), last_update) > 7)
ORDER BY days_since_stats_update DESC;
```
Flag: 🟡 If stats are older than 7 days, recommend running `ANALYZE TABLE` on affected tables before the upgrade. Stale statistics can cause the 8.0 optimizer (more cost-based than 5.7) to choose suboptimal plans right after upgrade. `ANALYZE TABLE` alone refreshes statistics — do NOT run `OPTIMIZE TABLE` routinely: on Aurora's InnoDB it triggers a full table rebuild (`ALTER TABLE ... FORCE`) under a metadata lock, warranted only for genuine dead-row bloat (high `DATA_FREE`), not for refreshing stats.
Action: For each table with stale stats:
```sql
ANALYZE TABLE schema_name.table_name;
-- Only if the table ALSO has significant dead-row bloat (high DATA_FREE), and in a
-- maintenance window — this rebuilds the table under a metadata lock:
-- OPTIMIZE TABLE schema_name.table_name;
```
## Result Analysis
After running queries, generate:
1. Categorized findings (🔴/🟡/🟢)
2. For each finding: what was found, why it matters, action to take
3. Recommended parameter group for target version preserving current behavior
references/upgrade-planning-query-load-mysql.md
# Aurora MySQL — Query Load Analysis & Explain Plan Review
## Purpose
Identify the top queries generating load, run EXPLAIN on them, and flag execution plan patterns that will behave differently after upgrading from Aurora MySQL 2 (5.7) to Aurora MySQL 3 (8.0).
## Step 1: Get Top 5 Queries by Load
### Option A: Performance Schema (preferred)
```sql
SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT/1000000000000 AS total_time_sec,
AVG_TIMER_WAIT/1000000000 AS avg_time_ms, SUM_ROWS_EXAMINED, SUM_ROWS_SENT,
FIRST_SEEN, LAST_SEEN
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME NOT IN ('mysql','information_schema','performance_schema','sys')
ORDER BY SUM_TIMER_WAIT DESC LIMIT 5;
```
### Option B: sys schema (if available)
```sql
SELECT query, exec_count, total_latency, avg_latency, rows_examined_avg, rows_sent_avg
FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 5;
```
## Step 2: Run EXPLAIN on Each Query
For each query from Step 1, run:
```sql
EXPLAIN FORMAT=JSON <query>;
```
If the query has parameters (using `?` placeholders from digest), substitute reasonable values or use:
```sql
EXPLAIN FORMAT=JSON SELECT ... FROM ... WHERE col = 1;
```
## Step 3: Flag Upgrade-Impacting Patterns
Analyze each EXPLAIN output for these patterns and flag accordingly:
### 🔴 Critical — Behavior Changes That Will Impact Performance
| Pattern in EXPLAIN | Why It Matters in 8.0 | Action |
|---|---|---|
| `"using_temporary_table": true` | MySQL 8.0 replaces the internal temp table engine. In 5.7, `MEMORY` engine is used for in-memory temp tables. In 8.0, `TempTable` engine is default with new parameters (`temptable_max_ram`, `temptable_max_mmap`). In 5.7, the on-disk internal temp-table engine defaults to InnoDB on the writer (set via `internal_tmp_disk_storage_engine`; readers use MyISAM). In 8.0, TempTable overflow goes to memory-mapped temporary files by default, or to InnoDB on-disk temp tables, controlled by `temptable_max_mmap` (readers always use TempTable/mmap in v3). | Set `internal_tmp_mem_storage_engine=TempTable` and tune `temptable_max_ram` (default 1GB). Monitor `Created_tmp_disk_tables` after upgrade. If queries relied on MEMORY engine behavior, test thoroughly. |
| `"using_filesort": true` with large `rows_examined` | The sort algorithm changed in 8.0. New optimizer may choose different sort strategies. | Benchmark these queries on a snapshot-restored test cluster before upgrade. |
| Hash join absent on large joins | MySQL 8.0 introduces hash joins for equi-joins without indexes. Optimizer may choose different plans. | Queries that were slow due to nested loop joins may improve, but verify with EXPLAIN on 8.0. |
### 🟡 Warning — Optimizer Behavior Differences
| Pattern in EXPLAIN | Why It Matters in 8.0 | Action |
|---|---|---|
| `"using_join_buffer": "Block Nested Loop"` | 8.0 may replace BNL with hash join for certain queries. Usually faster, but plan changes can surprise. | Test on snapshot cluster. Consider `optimizer_switch` to disable hash_join if regression found. |
| Derived table materialization (`"materialized_from_subquery"`) | 8.0 optimizer has improved derived table merging. Plans may change. | Usually beneficial. Monitor after upgrade. |
| `"index_merge"` usage | Index merge behavior refined in 8.0. | Verify same indexes are used post-upgrade. |
| Full table scan on small tables | 8.0 optimizer cost model updated. May choose index where 5.7 chose scan (or vice versa). | Run EXPLAIN on test cluster to compare. |
| `"query_cost"` significantly different | Cost model recalibrated in 8.0. | Use as baseline comparison only. |
### 🟢 Clean — No Upgrade Impact
| Pattern | Notes |
|---|---|
| Simple index lookups (`ref`, `eq_ref`, `const`) | No behavioral change expected. |
| Primary key lookups | Stable across versions. |
| Covering indexes (`Using index`) | No change. |
## Step 4: Generate Recommendations
For each flagged query, provide:
1. The query (truncated if long)
2. Current execution stats (calls, avg time, rows examined)
3. The problematic EXPLAIN pattern
4. Why it matters for the upgrade
5. Specific action: parameter to set, index to add, or test to run
## Key MySQL 8.0 Optimizer Changes to Watch For
- **Hash joins**: New in 8.0, replaces BNL for equi-joins without indexes
- **TempTable engine**: Replaces MEMORY for internal temp tables, different overflow behavior
- **Descending indexes**: Now supported natively (no more backward scans)
- **Invisible indexes**: Can test index removal without dropping
- **Histograms**: Optimizer can use column statistics for better cardinality estimates
- **Derived table merging**: Improved, may change plans for subqueries
- **Cost model**: Updated I/O and memory cost factors
- **GROUP BY no longer implicitly sorts**: Queries relying on implicit GROUP BY ordering will break
scripts/acu_calculator.py
"""Aurora Serverless v2 ACU Calculator.
Estimates ACU sizing, costs, and generates provisioned-vs-serverless comparisons.
Pricing & instance data: pulls live from AWS Pricing API + EC2/RDS APIs when
boto3 credentials are available, falls back to static defaults (us-east-1,
static fallback) when offline or credentials are missing.
Usage:
python acu_calculator.py --help
python acu_calculator.py estimate --instance db.r6g.xlarge --cpu-p95 35 --cpu-max 72 --storage 500
python acu_calculator.py estimate --region eu-west-1 --instance db.r6g.2xlarge --cpu-p95 20 --cpu-max 55 --connections 200 --storage 1000 --working-set 12
"""
import argparse
import json
import math
import re
import sys
from typing import Any
# ---------------------------------------------------------------------------
# Static fallback data (us-east-1)
# Used when AWS APIs are unavailable (no credentials, offline, API errors).
# ---------------------------------------------------------------------------
_STATIC_ACU_PRICE_STANDARD = 0.12 # $/ACU-Hr
_STATIC_ACU_PRICE_IO_OPTIMIZED = 0.156 # $/ACU-Hr (30% premium)
_STATIC_STORAGE_STANDARD_PER_GIB = 0.10 # $/GiB-month
_STATIC_STORAGE_IO_OPT_PER_GIB = 0.225 # $/GiB-month
_STATIC_LAST_UPDATED = "2026-03-28"
_STATIC_REGION = "us-east-1"
# Static instance specs: {name: (vcpus, memory_gib, price_per_hour)}
_STATIC_INSTANCE_SPECS = {
"db.t3.medium": (2, 4, 0.082),
"db.t3.large": (2, 8, 0.164),
"db.t4g.medium": (2, 4, 0.073),
"db.t4g.large": (2, 8, 0.146),
"db.r5.large": (2, 16, 0.290),
"db.r5.xlarge": (4, 32, 0.580),
"db.r5.2xlarge": (8, 64, 1.160),
"db.r5.4xlarge": (16, 128, 2.320),
"db.r5.8xlarge": (32, 256, 4.640),
"db.r5.12xlarge": (48, 384, 6.960),
"db.r5.16xlarge": (64, 512, 9.280),
"db.r5.24xlarge": (96, 768, 13.920),
"db.r6g.large": (2, 16, 0.260),
"db.r6g.xlarge": (4, 32, 0.519),
"db.r6g.2xlarge": (8, 64, 1.038),
"db.r6g.4xlarge": (16, 128, 2.076),
"db.r6g.8xlarge": (32, 256, 4.152),
"db.r6g.12xlarge": (48, 384, 6.228),
"db.r6g.16xlarge": (64, 512, 8.304),
"db.r7g.large": (2, 16, 0.276),
"db.r7g.xlarge": (4, 32, 0.553),
"db.r7g.2xlarge": (8, 64, 1.106),
"db.r7g.4xlarge": (16, 128, 2.211),
"db.r7g.8xlarge": (32, 256, 4.422),
"db.r7g.12xlarge": (48, 384, 6.633),
"db.r7g.16xlarge": (64, 512, 8.844),
"db.r8g.large": (2, 16, 0.276),
"db.r8g.xlarge": (4, 32, 0.552),
"db.r8g.2xlarge": (8, 64, 1.104),
"db.r8g.4xlarge": (16, 128, 2.208),
"db.r8g.8xlarge": (32, 256, 4.416),
"db.r8g.12xlarge": (48, 384, 6.624),
"db.r8g.16xlarge": (64, 512, 8.832),
"db.r8g.24xlarge": (96, 768, 13.248),
"db.r8g.48xlarge": (192, 1536, 26.496),
}
# ---------------------------------------------------------------------------
# Constants (non-pricing, do not vary by region)
# ---------------------------------------------------------------------------
# Aurora bills storage on actual usage per GiB-month with dynamic resizing —
# there is no fixed minimum billed storage. (No MIN_STORAGE_GIB floor.)
HOURS_PER_MONTH = 730
ACU_MIN = 0.5
ACU_MAX = 256
IO_OPT_COMPUTE_MULTIPLIER = 1.30 # I/O-Optimized compute premium
GIB_PER_ACU = 2.0 # Each ACU provides ~2 GiB of memory
# Instance family -> ACU ratio
ACU_FAMILY_RATIO = {"r": 4, "m": 2, "t": 2, "c": 1, "x": 4}
# AWS region code -> Pricing API "location" name
_REGION_NAMES = {
"us-east-1": "US East (N. Virginia)",
"us-east-2": "US East (Ohio)",
"us-west-1": "US West (N. California)",
"us-west-2": "US West (Oregon)",
"eu-west-1": "EU (Ireland)",
"eu-west-2": "EU (London)",
"eu-west-3": "EU (Paris)",
"eu-central-1": "EU (Frankfurt)",
"eu-north-1": "EU (Stockholm)",
"ap-southeast-1": "Asia Pacific (Singapore)",
"ap-southeast-2": "Asia Pacific (Sydney)",
"ap-northeast-1": "Asia Pacific (Tokyo)",
"ap-northeast-2": "Asia Pacific (Seoul)",
"ap-south-1": "Asia Pacific (Mumbai)",
"ca-central-1": "Canada (Central)",
"sa-east-1": "South America (Sao Paulo)",
}
# ---------------------------------------------------------------------------
# Active pricing & catalog (mutable — overwritten by refresh_pricing())
# ---------------------------------------------------------------------------
ACU_PRICE_STANDARD = _STATIC_ACU_PRICE_STANDARD
ACU_PRICE_IO_OPTIMIZED = _STATIC_ACU_PRICE_IO_OPTIMIZED
STORAGE_STANDARD_PER_GIB = _STATIC_STORAGE_STANDARD_PER_GIB
STORAGE_IO_OPT_PER_GIB = _STATIC_STORAGE_IO_OPT_PER_GIB
INSTANCE_SPECS = dict(_STATIC_INSTANCE_SPECS)
# Tracks where the active data came from
_pricing_source: dict[str, Any] = {
"source": "static_fallback",
"region": _STATIC_REGION,
"last_updated": _STATIC_LAST_UPDATED,
"details": "Built-in us-east-1 defaults",
}
# ---------------------------------------------------------------------------
# Live AWS API fetchers
# ---------------------------------------------------------------------------
def _fetch_instance_pricing(region: str) -> dict[str, float]:
"""Fetch on-demand hourly pricing for Aurora instances via the Pricing API.
The Pricing API is only available in us-east-1 and ap-south-1, but returns
pricing for any region. Queries aurora-mysql (covers all instance types).
Returns dict: instance_type -> price_per_hour.
"""
import boto3
location = _REGION_NAMES.get(region)
if not location:
raise ValueError(
f"Unknown region '{region}'. Supported: {', '.join(sorted(_REGION_NAMES))}"
)
pricing = boto3.client("pricing", region_name="us-east-1")
filters = [
{"Type": "TERM_MATCH", "Field": "databaseEngine", "Value": "Aurora MySQL"},
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
{"Type": "TERM_MATCH", "Field": "deploymentOption", "Value": "Single-AZ"},
{"Type": "TERM_MATCH", "Field": "termType", "Value": "OnDemand"},
]
prices = {}
paginator = pricing.get_paginator("get_products")
for page in paginator.paginate(ServiceCode="AmazonRDS", Filters=filters):
for item_json in page["PriceList"]:
item = json.loads(item_json) if isinstance(item_json, str) else item_json
attrs = item.get("product", {}).get("attributes", {})
instance_type = attrs.get("instanceType", "")
if not instance_type.startswith("db."):
continue
# Skip I/O-Optimized SKUs
if "IOOptimized" in attrs.get("usagetype", ""):
continue
terms = item.get("terms", {}).get("OnDemand", {})
for term in terms.values():
for dim in term.get("priceDimensions", {}).values():
try:
price = float(dim.get("pricePerUnit", {}).get("USD", "0"))
except (ValueError, TypeError):
continue
if price > 0:
prices[instance_type] = price
return prices
def _fetch_instance_pricing_bulk(region: str) -> dict[str, float]:
"""Fetch on-demand Aurora MySQL pricing from the public AWS Bulk Pricing CSV.
No IAM credentials required — this is a publicly accessible HTTPS endpoint.
Used as a fallback when the Pricing API is not accessible (AccessDeniedException).
Returns dict: instance_type -> price_per_hour (Aurora Standard only).
"""
import csv
import io
import urllib.request
url = (
f"https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonRDS/"
f"current/{region}/index.csv"
)
req = urllib.request.Request(url, headers={"Accept-Encoding": "identity"})
with urllib.request.urlopen(req, timeout=30) as resp:
# CSV has metadata rows before the header; find the header row
raw = resp.read().decode("utf-8")
# AWS pricing CSVs start with metadata lines (FormatVersion, Disclaimer, etc.)
# before the actual column header. Find the header row (starts with "SKU").
lines = raw.splitlines()
header_idx = 0
for i, line in enumerate(lines):
if line.startswith('"SKU"') or line.startswith("SKU"):
header_idx = i
break
reader = csv.DictReader(io.StringIO("\n".join(lines[header_idx:])))
prices = {}
for row in reader:
if row.get("Database Engine") != "Aurora MySQL":
continue
if row.get("Deployment Option") != "Single-AZ":
continue
# On-demand hourly rates only. The CSV also carries Reserved rows
# (including fixed-fee Quantity rows with values like 2530), which would
# otherwise overwrite the real hourly price via last-write-wins.
if row.get("TermType") != "OnDemand" or row.get("Unit") != "Hrs":
continue
instance_type = row.get("Instance Type", "")
if not instance_type.startswith("db."):
continue
# Skip I/O-Optimized SKUs. The column is "usageType" (camelCase) in the
# AWS RDS bulk CSV; I/O-Optimized is encoded there as InstanceUsageIOOptimized:*.
usage_type = row.get("usageType", "")
if "IOOptimized" in usage_type:
continue
try:
price = float(row.get("PricePerUnit", "0"))
except (ValueError, TypeError):
continue
if price > 0:
prices[instance_type] = price
return prices
def _fetch_acu_and_storage_pricing(region: str) -> dict:
"""Fetch ACU and storage pricing from the Pricing API.
Returns dict with acu_standard, acu_io_optimized, storage_standard,
storage_io_optimized keys.
"""
import boto3
location = _REGION_NAMES.get(region)
if not location:
raise ValueError(f"Unknown region '{region}'")
pricing = boto3.client("pricing", region_name="us-east-1")
result = {}
# ACU pricing
acu_filters = [
{"Type": "TERM_MATCH", "Field": "databaseEngine", "Value": "Aurora MySQL"},
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
{"Type": "TERM_MATCH", "Field": "termType", "Value": "OnDemand"},
]
paginator = pricing.get_paginator("get_products")
for page in paginator.paginate(ServiceCode="AmazonRDS", Filters=acu_filters):
for item_json in page["PriceList"]:
item = json.loads(item_json) if isinstance(item_json, str) else item_json
attrs = item.get("product", {}).get("attributes", {})
usagetype = attrs.get("usagetype", "")
if "ServerlessV2Usage" in usagetype and "IOOptimized" not in usagetype:
terms = item.get("terms", {}).get("OnDemand", {})
for term in terms.values():
for dim in term.get("priceDimensions", {}).values():
price = float(dim.get("pricePerUnit", {}).get("USD", "0"))
if price > 0:
result["acu_standard"] = price
result["acu_io_optimized"] = round(price * IO_OPT_COMPUTE_MULTIPLIER, 4)
# Storage pricing
storage_filters = [
{"Type": "TERM_MATCH", "Field": "databaseEngine", "Value": "Aurora MySQL"},
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
{"Type": "TERM_MATCH", "Field": "termType", "Value": "OnDemand"},
{"Type": "TERM_MATCH", "Field": "productFamily", "Value": "Database Storage"},
]
for page in paginator.paginate(ServiceCode="AmazonRDS", Filters=storage_filters):
for item_json in page["PriceList"]:
item = json.loads(item_json) if isinstance(item_json, str) else item_json
attrs = item.get("product", {}).get("attributes", {})
usagetype = attrs.get("usagetype", "")
terms = item.get("terms", {}).get("OnDemand", {})
for term in terms.values():
for dim in term.get("priceDimensions", {}).values():
price = float(dim.get("pricePerUnit", {}).get("USD", "0"))
if price <= 0:
continue
if "IOOptimized" in usagetype:
result["storage_io_optimized"] = price
elif "Aurora:StorageUsage" in usagetype:
result["storage_standard"] = price
return result
def _fetch_instance_catalog(region: str) -> dict[str, tuple]:
"""Discover Aurora instance types via RDS + EC2 APIs.
Returns dict: instance_type -> (vcpus, memory_gib, 0.0).
Price is 0.0 here; caller merges with pricing data.
"""
import boto3
rds = boto3.client("rds", region_name=region)
instances = {}
for engine in ("aurora-mysql",):
try:
paginator = rds.get_paginator("describe_orderable_db_instance_options")
for page in paginator.paginate(Engine=engine):
for opt in page.get("OrderableDBInstanceOptions", []):
name = opt.get("DBInstanceClass", "")
if name.startswith("db.") and name != "db.serverless":
instances[name] = {"vcpus": 0, "memory_gib": 0.0}
except Exception:
pass
if not instances:
return {}
# Get vCPU/memory from EC2 describe_instance_types
ec2 = boto3.client("ec2", region_name=region)
ec2_names = [name.replace("db.", "", 1) for name in instances]
for i in range(0, len(ec2_names), 100):
batch = ec2_names[i : i + 100]
try:
resp = ec2.describe_instance_types(InstanceTypes=batch)
for it in resp.get("InstanceTypes", []):
db_name = "db." + it["InstanceType"]
if db_name in instances:
instances[db_name]["vcpus"] = it.get("VCpuInfo", {}).get("DefaultVCpus", 0)
instances[db_name]["memory_gib"] = (
it.get("MemoryInfo", {}).get("SizeInMiB", 0) / 1024
)
except Exception:
# Try one by one for families EC2 doesn't know about
for ec2_name in batch:
try:
resp = ec2.describe_instance_types(InstanceTypes=[ec2_name])
for it in resp.get("InstanceTypes", []):
db_name = "db." + it["InstanceType"]
if db_name in instances:
instances[db_name]["vcpus"] = it.get("VCpuInfo", {}).get(
"DefaultVCpus", 0
)
instances[db_name]["memory_gib"] = (
it.get("MemoryInfo", {}).get("SizeInMiB", 0) / 1024
)
except Exception:
pass
# Convert to tuple format, drop entries missing vCPU/memory
catalog = {}
for name, spec in instances.items():
if spec["vcpus"] > 0 and spec["memory_gib"] > 0:
catalog[name] = (spec["vcpus"], spec["memory_gib"], 0.0)
return catalog
def refresh_pricing(region: str = "us-east-1") -> dict:
"""Refresh all pricing and instance data from AWS APIs.
Tries live APIs first. On any failure, falls back to static defaults
and reports what succeeded and what didn't.
Returns a summary dict describing the pricing source.
"""
global ACU_PRICE_STANDARD, ACU_PRICE_IO_OPTIMIZED
global STORAGE_STANDARD_PER_GIB, STORAGE_IO_OPT_PER_GIB
global INSTANCE_SPECS, _pricing_source
errors = []
live_instances = 0
live_prices = 0
live_acu = False
# 1. Try to fetch the instance catalog (RDS + EC2)
api_catalog = {}
try:
api_catalog = _fetch_instance_catalog(region)
live_instances = len(api_catalog)
except Exception as e:
errors.append(f"Instance catalog: {e}")
# 2. Try to fetch instance pricing (Pricing API, then public bulk CSV fallback)
instance_prices = {}
try:
instance_prices = _fetch_instance_pricing(region)
live_prices = len(instance_prices)
except Exception as e:
errors.append(f"Instance pricing (API): {e}")
# Fallback: public bulk pricing CSV (no IAM credentials needed)
try:
instance_prices = _fetch_instance_pricing_bulk(region)
live_prices = len(instance_prices)
if live_prices > 0:
errors[-1] += " [recovered via bulk pricing CSV]"
except Exception as e2:
errors.append(f"Instance pricing (bulk CSV): {e2}")
# 3. Try to fetch ACU + storage pricing
try:
acu_data = _fetch_acu_and_storage_pricing(region)
if "acu_standard" in acu_data:
ACU_PRICE_STANDARD = acu_data["acu_standard"]
ACU_PRICE_IO_OPTIMIZED = acu_data.get(
"acu_io_optimized",
round(acu_data["acu_standard"] * IO_OPT_COMPUTE_MULTIPLIER, 4),
)
live_acu = True
if "storage_standard" in acu_data:
STORAGE_STANDARD_PER_GIB = acu_data["storage_standard"]
if "storage_io_optimized" in acu_data:
STORAGE_IO_OPT_PER_GIB = acu_data["storage_io_optimized"]
except Exception as e:
errors.append(f"ACU/storage pricing: {e}")
# 4. Merge: start with static, overlay API catalog, overlay prices
merged = dict(_STATIC_INSTANCE_SPECS)
for name, (vcpus, mem, _) in api_catalog.items():
price = instance_prices.get(name, 0.0)
# If API didn't return a price, keep static price if we have one
if price == 0.0 and name in _STATIC_INSTANCE_SPECS:
price = _STATIC_INSTANCE_SPECS[name][2]
merged[name] = (vcpus, mem, price)
# For instances in static but not in API catalog, update price if available
for name in _STATIC_INSTANCE_SPECS:
if name not in api_catalog and name in instance_prices:
v, m, _ = _STATIC_INSTANCE_SPECS[name]
merged[name] = (v, m, instance_prices[name])
INSTANCE_SPECS = merged
# Determine source description
if not errors:
source = "live"
details = (
f"Live AWS APIs ({region}): {live_instances} instance types, "
f"{live_prices} prices, ACU=${ACU_PRICE_STANDARD}/hr"
)
elif live_prices > 0 or live_acu:
source = "partial_live"
details = (
f"Partial live data ({region}): {live_instances} instances, "
f"{live_prices} prices. Gaps filled from static defaults. "
f"Errors: {'; '.join(errors)}"
)
else:
# Full fallback
source = "static_fallback"
INSTANCE_SPECS = dict(_STATIC_INSTANCE_SPECS)
ACU_PRICE_STANDARD = _STATIC_ACU_PRICE_STANDARD
ACU_PRICE_IO_OPTIMIZED = _STATIC_ACU_PRICE_IO_OPTIMIZED
STORAGE_STANDARD_PER_GIB = _STATIC_STORAGE_STANDARD_PER_GIB
STORAGE_IO_OPT_PER_GIB = _STATIC_STORAGE_IO_OPT_PER_GIB
details = (
f"Static fallback (us-east-1, {_STATIC_LAST_UPDATED}). "
f"Live fetch failed: {'; '.join(errors)}"
)
_pricing_source = {
"source": source,
"region": region,
"last_updated": _STATIC_LAST_UPDATED if source == "static_fallback" else "now",
"details": details,
"instance_count": len(INSTANCE_SPECS),
"acu_price_standard": ACU_PRICE_STANDARD,
"storage_price_standard": STORAGE_STANDARD_PER_GIB,
}
if errors:
_pricing_source["errors"] = errors
return _pricing_source
def get_pricing_source() -> dict:
"""Return metadata about the active pricing data source."""
return dict(_pricing_source)
# ---------------------------------------------------------------------------
# Core calculation functions
# ---------------------------------------------------------------------------
def round_up_to_half(value: float) -> float:
"""Round up to nearest 0.5 ACU."""
return math.ceil(value * 2) / 2
def family_ratio(instance_type: str) -> int:
"""Get ACU ratio for an instance family."""
m = re.match(r"db\.([a-z])", instance_type)
if m:
return ACU_FAMILY_RATIO.get(m.group(1), 4)
return 4
def get_instance_specs(instance_type: str) -> tuple:
"""Get (vcpus, memory_gib, price_per_hour) for an instance type."""
if instance_type in INSTANCE_SPECS:
return INSTANCE_SPECS[instance_type]
raise ValueError(
f"Unknown instance type: {instance_type}. "
f"Supported: {', '.join(sorted(INSTANCE_SPECS.keys()))}"
)
def estimate_acu(
cpu_p95: float,
cpu_max: float,
vcpus: int,
instance_type: str,
cpu_avg: float = 0,
) -> dict:
"""Estimate ACU needed for a workload.
Returns dict with typical ACU, min/max recommendations, and breakdown.
"""
ratio = family_ratio(instance_type)
# Typical ACU: weighted 95/5 blend
weighted_cpu = (cpu_p95 * 0.95 + cpu_max * 0.05) / 100
raw_typical = weighted_cpu * vcpus * ratio
typical_acu = round_up_to_half(raw_typical)
typical_acu = max(ACU_MIN, min(typical_acu, ACU_MAX))
# Peak ACU
raw_peak = (cpu_max / 100) * vcpus * ratio
peak_acu = round_up_to_half(raw_peak)
# Average ACU (for min recommendation)
if cpu_avg > 0:
avg_acu = round_up_to_half((cpu_avg / 100) * vcpus * ratio)
else:
# Estimate average as 60% of P95 when not provided
avg_acu = round_up_to_half((cpu_p95 * 0.6 / 100) * vcpus * ratio)
exceeds_capacity = raw_typical > ACU_MAX or raw_peak > ACU_MAX
return {
"typical_acu": typical_acu,
"peak_acu": peak_acu,
"avg_acu": avg_acu,
"raw_typical": round(raw_typical, 2),
"raw_peak": round(raw_peak, 2),
"family_ratio": ratio,
"exceeds_capacity": exceeds_capacity,
}
def recommend_min_max(
acu_result: dict,
connections: int = 0,
working_set_gib: float = 0,
) -> dict:
"""Recommend min/max ACU settings."""
avg_acu = acu_result["avg_acu"]
typical_acu = acu_result["typical_acu"]
peak_acu = acu_result["peak_acu"]
# Connection floor
conn_mem_gib = connections * 10 / 1024 # ~10 MB per connection average
conn_acu = round_up_to_half(conn_mem_gib / GIB_PER_ACU)
# Memory floor (advisory — working set)
mem_acu = round_up_to_half(working_set_gib / GIB_PER_ACU) if working_set_gib > 0 else 0
# Min: based on avg CPU + connection floor (uncapped first, so we can detect
# a baseline that already exceeds serverless limits).
raw_min = max(ACU_MIN, avg_acu, conn_acu)
# Max: peak + 30% headroom, at least 1.5x typical
recommended_max = round_up_to_half(peak_acu * 1.3)
recommended_max = max(recommended_max, round_up_to_half(typical_acu * 1.5))
recommended_max = min(recommended_max, ACU_MAX)
# The workload's baseline doesn't fit a single serverless instance when the
# uncapped min exceeds the ACU ceiling or the (capped) max — flag it, mirroring
# estimate_acu's exceeds_capacity.
exceeds_capacity = raw_min > ACU_MAX or raw_min > recommended_max
# Cap min at ACU_MAX and enforce the invariant: min must never exceed max.
recommended_min = min(raw_min, ACU_MAX, recommended_max)
# Memory advisory
memory_advisory = None
if mem_acu > recommended_min:
memory_advisory = (
f"Working set needs {mem_acu} ACU ({working_set_gib:.1f} GiB / "
f"{GIB_PER_ACU} GiB per ACU). Your min ACU ({recommended_min}) is below this. "
f"Setting min to {mem_acu} ACU keeps the working set cached and avoids "
f"cold-cache I/O penalties on scale-up. Trade-off: higher baseline cost."
)
return {
"recommended_min": recommended_min,
"recommended_max": recommended_max,
"connection_floor_acu": conn_acu,
"memory_floor_acu": mem_acu,
"memory_advisory": memory_advisory,
"exceeds_capacity": exceeds_capacity,
}
def calculate_costs(
typical_acu: float,
min_acu: float,
max_acu: float,
storage_gib: float,
provisioned_instance: str,
num_provisioned_instances: int = 1,
exceeds_capacity: bool = False,
) -> dict:
"""Calculate and compare serverless vs provisioned costs."""
_, _, price_per_hour = get_instance_specs(provisioned_instance)
# Provisioned cost
prov_compute = price_per_hour * HOURS_PER_MONTH * num_provisioned_instances
prov_storage = storage_gib * STORAGE_STANDARD_PER_GIB
prov_total = prov_compute + prov_storage
# Serverless cost (typical steady-state)
sv_compute = typical_acu * ACU_PRICE_STANDARD * HOURS_PER_MONTH
sv_storage = storage_gib * STORAGE_STANDARD_PER_GIB
sv_total = sv_compute + sv_storage
# Serverless cost range
sv_low = min_acu * ACU_PRICE_STANDARD * HOURS_PER_MONTH + sv_storage
sv_high = max_acu * ACU_PRICE_STANDARD * HOURS_PER_MONTH + sv_storage
# Savings
savings = prov_total - sv_total
savings_pct = (savings / prov_total * 100) if prov_total > 0 else 0
# Recommendation logic
if exceeds_capacity:
recommendation = "not_recommended"
reason = (
f"Workload's baseline/peak demand exceeds the {ACU_MAX:.0f} ACU serverless "
f"maximum. Stay with provisioned or split across multiple serverless clusters."
)
elif savings_pct > 10 and sv_high <= prov_total * 2:
recommendation = "recommended"
reason = (
f"Serverless saves ${savings:.0f}/mo ({savings_pct:.0f}%) vs provisioned. "
f"Cost range: ${sv_low:.0f}–${sv_high:.0f}/mo."
)
elif savings_pct > 10:
recommendation = "consider"
reason = (
f"Typical cost is lower (${sv_total:.0f} vs ${prov_total:.0f}/mo), but "
f"peak cost could reach ${sv_high:.0f}/mo. Variable workloads benefit; "
f"sustained peaks may not."
)
elif savings_pct > -5:
recommendation = "consider"
reason = (
f"Similar cost (${sv_total:.0f} vs ${prov_total:.0f}/mo). Choose serverless "
f"for auto-scaling and zero management overhead."
)
elif savings_pct > -30:
recommendation = "more_expensive"
reason = (
f"Serverless costs ${abs(savings):.0f}/mo more than provisioned "
f"(${sv_total:.0f} vs ${prov_total:.0f}/mo)."
)
else:
recommendation = "not_recommended"
reason = (
f"Serverless at ${sv_total:.0f}/mo is {abs(savings_pct):.0f}% more expensive "
f"than provisioned at ${prov_total:.0f}/mo. Sustained workloads are cheaper "
f"on provisioned instances."
)
return {
"provisioned": {
"instance_type": provisioned_instance,
"num_instances": num_provisioned_instances,
"compute_monthly": round(prov_compute, 2),
"storage_monthly": round(prov_storage, 2),
"total_monthly": round(prov_total, 2),
},
"serverless": {
"typical_acu": typical_acu,
"compute_monthly": round(sv_compute, 2),
"storage_monthly": round(sv_storage, 2),
"total_monthly": round(sv_total, 2),
"cost_range": {
"low": round(sv_low, 2),
"typical": round(sv_total, 2),
"high": round(sv_high, 2),
},
},
"savings_monthly": round(savings, 2),
"savings_pct": round(savings_pct, 1),
"recommendation": recommendation,
"reason": reason,
}
def format_table(result: dict) -> str:
"""Format result as a readable text table."""
lines = []
source = result.get("pricing_source", _pricing_source)
tag = source.get("source", "static_fallback").replace("_", " ").title()
lines.append("=" * 65)
lines.append(" Aurora Serverless v2 — ACU Estimate & Cost Comparison")
lines.append(f" Pricing: {tag} ({source.get('region', '?')})")
lines.append("=" * 65)
# ACU settings
acu = result["acu_settings"]
lines.append("")
lines.append(" ACU Configuration")
lines.append(" " + "-" * 45)
lines.append(f" Recommended Min ACU: {acu['recommended_min']:.1f}")
lines.append(f" Recommended Max ACU: {acu['recommended_max']:.1f}")
lines.append(f" Typical ACU: {acu['typical_acu']:.1f}")
lines.append(f" Peak ACU: {acu['peak_acu']:.1f}")
if acu.get("connection_floor_acu", 0) > 0:
lines.append(f" Connection floor: {acu['connection_floor_acu']:.1f} ACU")
if acu.get("memory_floor_acu", 0) > 0:
lines.append(f" Memory floor: {acu['memory_floor_acu']:.1f} ACU (advisory)")
if acu.get("memory_advisory"):
lines.append(f" NOTE: {acu['memory_advisory']}")
# Cost comparison
costs = result["cost_comparison"]
prov = costs["provisioned"]
sv = costs["serverless"]
lines.append("")
lines.append(" Monthly Cost Comparison")
lines.append(" " + "-" * 45)
lines.append(f" {'':30s} {'Provisioned':>14s} {'Serverless':>14s}")
lines.append(
f" {'Compute':30s} {'$'+str(prov['compute_monthly']):>14s} {'$'+str(sv['compute_monthly']):>14s}"
)
lines.append(
f" {'Storage':30s} {'$'+str(prov['storage_monthly']):>14s} {'$'+str(sv['storage_monthly']):>14s}"
)
lines.append(
f" {'Total':30s} {'$'+str(prov['total_monthly']):>14s} {'$'+str(sv['total_monthly']):>14s}"
)
lines.append("")
lines.append(
f" Serverless cost range: ${sv['cost_range']['low']:.0f} – ${sv['cost_range']['high']:.0f}/mo"
)
lines.append(f" Savings: ${costs['savings_monthly']:.0f}/mo ({costs['savings_pct']:.0f}%)")
# Recommendation
lines.append("")
lines.append(f" Recommendation: {costs['recommendation'].upper()}")
lines.append(f" {costs['reason']}")
lines.append("")
lines.append("=" * 65)
return "\n".join(lines)
def run_estimate(args) -> dict:
"""Run full estimation from CLI arguments."""
vcpus, memory_gib, price = get_instance_specs(args.instance)
acu_result = estimate_acu(
cpu_p95=args.cpu_p95,
cpu_max=args.cpu_max,
vcpus=vcpus,
instance_type=args.instance,
cpu_avg=args.cpu_avg,
)
min_max = recommend_min_max(
acu_result,
connections=args.connections,
working_set_gib=args.working_set,
)
# Workload overflows serverless if EITHER signal trips: estimate_acu's
# typical/peak check, or recommend_min_max's baseline-min check.
exceeds_capacity = acu_result["exceeds_capacity"] or min_max["exceeds_capacity"]
costs = calculate_costs(
typical_acu=acu_result["typical_acu"],
min_acu=min_max["recommended_min"],
max_acu=min_max["recommended_max"],
storage_gib=args.storage,
provisioned_instance=args.instance,
num_provisioned_instances=args.num_instances,
exceeds_capacity=exceeds_capacity,
)
return {
"input": {
"instance_type": args.instance,
"vcpus": vcpus,
"memory_gib": memory_gib,
"cpu_p95": args.cpu_p95,
"cpu_max": args.cpu_max,
"cpu_avg": args.cpu_avg,
"connections": args.connections,
"working_set_gib": args.working_set,
"storage_gib": args.storage,
"num_instances": args.num_instances,
},
"acu_settings": {
"recommended_min": min_max["recommended_min"],
"recommended_max": min_max["recommended_max"],
"typical_acu": acu_result["typical_acu"],
"peak_acu": acu_result["peak_acu"],
"avg_acu": acu_result["avg_acu"],
"connection_floor_acu": min_max["connection_floor_acu"],
"memory_floor_acu": min_max["memory_floor_acu"],
"memory_advisory": min_max["memory_advisory"],
"exceeds_capacity": exceeds_capacity,
},
"cost_comparison": costs,
"pricing_source": get_pricing_source(),
}
def main():
# Shared flags accepted both before the subcommand and after it (so e.g.
# `... estimate --region X --offline` and `... --region X estimate` both work).
# Defaults are SUPPRESSed here so a subparser copy does NOT re-apply its own
# default and clobber a value the user passed before the subcommand; the real
# defaults are resolved once, after parsing, below.
common = argparse.ArgumentParser(add_help=False)
common.add_argument(
"--region",
default=argparse.SUPPRESS,
help="AWS region for pricing (default: us-east-1). "
"Live pricing requires boto3 + AWS credentials.",
)
common.add_argument(
"--offline",
action="store_true",
default=argparse.SUPPRESS,
help="Skip live API calls, use static fallback data only.",
)
parser = argparse.ArgumentParser(
description="Aurora Serverless v2 ACU Calculator", parents=[common]
)
sub = parser.add_subparsers(dest="command")
# estimate command
est = sub.add_parser("estimate", parents=[common], help="Estimate ACU sizing and compare costs")
est.add_argument(
"--instance", required=True, help="Current provisioned instance type (e.g., db.r6g.xlarge)"
)
est.add_argument("--cpu-p95", type=float, required=True, help="P95 CPU utilization (0-100)")
est.add_argument("--cpu-max", type=float, required=True, help="Maximum CPU utilization (0-100)")
est.add_argument(
"--cpu-avg",
type=float,
default=0,
help="Average CPU utilization (0-100), estimated if omitted",
)
est.add_argument("--connections", type=int, default=0, help="Peak connection count")
est.add_argument("--working-set", type=float, default=0, help="Working set size in GiB")
est.add_argument("--storage", type=float, default=10, help="Storage in GiB")
est.add_argument(
"--num-instances",
type=int,
default=1,
help="Number of provisioned instances (for cost comparison)",
)
est.add_argument("--format", choices=["json", "table"], default="json", help="Output format")
# list-instances command
sub.add_parser(
"list-instances",
parents=[common],
help="List supported instance types with specs and pricing",
)
# pricing-source command
sub.add_parser(
"pricing-source", parents=[common], help="Show where pricing data is coming from"
)
args = parser.parse_args()
# Resolve shared-flag defaults once (they were SUPPRESSed on both the main and
# subparsers so neither position clobbers the other; an explicit flag in either
# position lands in the namespace, otherwise we apply the default here).
if not hasattr(args, "region"):
args.region = "us-east-1"
if not hasattr(args, "offline"):
args.offline = False
# Refresh pricing (live or offline)
if not args.offline:
source = refresh_pricing(region=args.region)
if args.command != "pricing-source":
# Brief status line to stderr so it doesn't pollute JSON output
tag = (
"LIVE"
if source["source"] == "live"
else ("PARTIAL" if source["source"] == "partial_live" else "STATIC")
)
print(
f"[Pricing: {tag} — {source['region']}, "
f"{source['instance_count']} instances, "
f"ACU=${source['acu_price_standard']}/hr]",
file=sys.stderr,
)
if args.command == "estimate":
result = run_estimate(args)
if args.format == "table":
print(format_table(result))
else:
print(json.dumps(result, indent=2))
elif args.command == "list-instances":
source = get_pricing_source()
print(f"Pricing source: {source['source']} ({source['region']})")
print(f"{'Instance Type':<25s} {'vCPUs':>6s} {'Memory':>8s} {'$/hr':>8s} {'$/mo':>10s}")
print("-" * 62)
for name in sorted(INSTANCE_SPECS.keys()):
v, m, p = INSTANCE_SPECS[name]
print(f"{name:<25s} {v:>6d} {m:>6.0f} GiB {p:>8.3f} {p*730:>10.2f}")
print(f"\nTotal: {len(INSTANCE_SPECS)} instance types")
elif args.command == "pricing-source":
print(json.dumps(get_pricing_source(), indent=2))
else:
parser.print_help()
if __name__ == "__main__":
main()
scripts/commitment_pricing_analyzer.py
"""Aurora Reserved Instance & Database Savings Plan estimator.
Read-only tool that fetches live RI and DSP rates from AWS and projects
monthly cost under each commitment option for a cluster, fleet, or
user-supplied workload. No purchase APIs are ever called.
Usage:
python commitment_pricing_analyzer.py --cluster my-cluster --region us-east-1
python commitment_pricing_analyzer.py --all --region us-east-1
python commitment_pricing_analyzer.py offline \\
--instance db.r7g.2xlarge --num-instances 2 --region us-east-1
python commitment_pricing_analyzer.py offline \\
--serverless --avg-acu 8 --region us-east-1
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
HOURS_PER_MONTH = 730
# ---------------------------------------------------------------------------
# Static fallback on-demand prices (us-east-1, Aurora MySQL Standard;
# Aurora MySQL and PostgreSQL share these instance rates).
# I/O-Optimized premium applied via multiplier.
# ---------------------------------------------------------------------------
IO_OPT_COMPUTE_MULTIPLIER = 1.30
ACU_PRICE_STANDARD = 0.12 # $/ACU-Hr
ACU_PRICE_IO_OPTIMIZED = 0.156
_STATIC_INSTANCE_PRICES = {
"db.t3.medium": 0.082,
"db.t3.large": 0.164,
"db.t4g.medium": 0.073,
"db.t4g.large": 0.146,
"db.r5.large": 0.290,
"db.r5.xlarge": 0.580,
"db.r5.2xlarge": 1.160,
"db.r5.4xlarge": 2.320,
"db.r5.8xlarge": 4.640,
"db.r5.12xlarge": 6.960,
"db.r5.16xlarge": 9.280,
"db.r5.24xlarge": 13.920,
"db.r6g.large": 0.260,
"db.r6g.xlarge": 0.519,
"db.r6g.2xlarge": 1.038,
"db.r6g.4xlarge": 2.076,
"db.r6g.8xlarge": 4.152,
"db.r6g.12xlarge": 6.228,
"db.r6g.16xlarge": 8.304,
"db.r7g.large": 0.276,
"db.r7g.xlarge": 0.553,
"db.r7g.2xlarge": 1.106,
"db.r7g.4xlarge": 2.211,
"db.r7g.8xlarge": 4.422,
"db.r7g.12xlarge": 6.633,
"db.r7g.16xlarge": 8.844,
"db.r8g.large": 0.276,
"db.r8g.xlarge": 0.552,
"db.r8g.2xlarge": 1.104,
"db.r8g.4xlarge": 2.208,
"db.r8g.8xlarge": 4.416,
"db.r8g.12xlarge": 6.624,
"db.r8g.16xlarge": 8.832,
"db.r8g.24xlarge": 13.248,
"db.r8g.48xlarge": 26.496,
}
_REGION_NAMES = {
"us-east-1": "US East (N. Virginia)",
"us-east-2": "US East (Ohio)",
"us-west-1": "US West (N. California)",
"us-west-2": "US West (Oregon)",
"eu-west-1": "EU (Ireland)",
"eu-west-2": "EU (London)",
"eu-central-1": "EU (Frankfurt)",
"eu-north-1": "EU (Stockholm)",
"ap-southeast-1": "Asia Pacific (Singapore)",
"ap-southeast-2": "Asia Pacific (Sydney)",
"ap-northeast-1": "Asia Pacific (Tokyo)",
"ap-south-1": "Asia Pacific (Mumbai)",
"ca-central-1": "Canada (Central)",
}
# DSP only covers these families
_DSP_ELIGIBLE_FAMILIES = {"r7g", "r7i", "r8g", "r8gd", "m7g", "m7i", "c7g", "c7i", "x8g"}
_DSP_SIZE_MAP = {
"micro": "micro",
"small": "small",
"medium": "medium",
"large": "large",
"xl": "xlarge",
"2xl": "2xlarge",
"4xl": "4xlarge",
"8xl": "8xlarge",
"12xl": "12xlarge",
"16xl": "16xlarge",
"24xl": "24xlarge",
"48xl": "48xlarge",
}
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class RIOffering:
instance_type: str
term_years: int
payment_option: str # "No Upfront" | "Partial Upfront" | "All Upfront"
effective_hourly: float # (upfront / term_hours) + recurring
upfront_cost: float
recurring_hourly: float
def monthly_cost(self) -> float:
return self.effective_hourly * HOURS_PER_MONTH
@dataclass
class DSPRate:
usage_type: str # instance type or "ServerlessV2"
term_years: int # always 1 for Aurora DSP
payment_option: str
rate_per_hour: float
def monthly_cost(self) -> float:
return self.rate_per_hour * HOURS_PER_MONTH
# ---------------------------------------------------------------------------
# Live AWS fetchers
# ---------------------------------------------------------------------------
def _family_from_instance(instance_type: str) -> str:
m = re.match(r"db\.([a-z0-9]+)\.", instance_type)
return m.group(1) if m else ""
def get_on_demand_price(instance_type: str, region: str = "us-east-1") -> float:
"""Return on-demand hourly price. Tries Pricing API, falls back to static."""
try:
import boto3
except ImportError:
return _STATIC_INSTANCE_PRICES.get(instance_type, 0.0)
location = _REGION_NAMES.get(region)
if not location:
return _STATIC_INSTANCE_PRICES.get(instance_type, 0.0)
try:
pricing = boto3.client("pricing", region_name="us-east-1")
filters = [
{"Type": "TERM_MATCH", "Field": "databaseEngine", "Value": "Aurora MySQL"},
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
{"Type": "TERM_MATCH", "Field": "instanceType", "Value": instance_type},
{"Type": "TERM_MATCH", "Field": "deploymentOption", "Value": "Single-AZ"},
{"Type": "TERM_MATCH", "Field": "termType", "Value": "OnDemand"},
]
for page in pricing.get_paginator("get_products").paginate(
ServiceCode="AmazonRDS", Filters=filters
):
for item_json in page["PriceList"]:
item = json.loads(item_json) if isinstance(item_json, str) else item_json
attrs = item.get("product", {}).get("attributes", {})
if "IOOptimized" in attrs.get("usagetype", ""):
continue
for term in item.get("terms", {}).get("OnDemand", {}).values():
for dim in term.get("priceDimensions", {}).values():
try:
price = float(dim.get("pricePerUnit", {}).get("USD", "0"))
except (ValueError, TypeError):
continue
if price > 0:
return price
except Exception:
pass
return _STATIC_INSTANCE_PRICES.get(instance_type, 0.0)
def fetch_ri_offerings(instance_type: str, region: str) -> list[RIOffering]:
"""Fetch all RI offerings for an instance type. Returns [] on failure."""
try:
import boto3
except ImportError:
return []
results: list[RIOffering] = []
try:
rds = boto3.client("rds", region_name=region)
for engine in ("aurora-mysql",):
try:
paginator = rds.get_paginator("describe_reserved_db_instances_offerings")
for page in paginator.paginate(
DBInstanceClass=instance_type,
ProductDescription=engine,
MultiAZ=False,
):
for offering in page.get("ReservedDBInstancesOfferings", []):
inst = offering.get("DBInstanceClass", "")
if inst != instance_type:
continue
duration = offering.get("Duration", 0)
term_years = 3 if duration > 94_000_000 else 1
payment = offering.get("OfferingType", "")
fixed = float(offering.get("FixedPrice", 0.0))
recurring_list = offering.get("RecurringCharges", [])
recurring_hr = sum(
float(rc.get("RecurringChargeAmount", 0.0)) for rc in recurring_list
)
term_hours = term_years * 365 * 24
effective = (fixed / term_hours) + recurring_hr
results.append(
RIOffering(
instance_type=inst,
term_years=term_years,
payment_option=payment,
effective_hourly=round(effective, 6),
upfront_cost=round(fixed, 2),
recurring_hourly=round(recurring_hr, 6),
)
)
except Exception:
continue
except Exception:
return []
# Deduplicate (same offering exists for both engines)
seen = set()
deduped = []
for r in results:
key = (r.term_years, r.payment_option, round(r.effective_hourly, 6))
if key in seen:
continue
seen.add(key)
deduped.append(r)
return deduped
def fetch_dsp_rates(region: str) -> dict[str, list[DSPRate]]:
"""Fetch Database Savings Plan rates for Aurora in the region.
Returns dict mapping usage key (instance type or 'ServerlessV2') to rates.
"""
try:
import boto3
except ImportError:
return {}
result: dict[str, list[DSPRate]] = {}
try:
sp = boto3.client("savingsplans", region_name="us-east-1")
except Exception:
return {}
for engine in ("Aurora MySQL",):
try:
rates = []
token = None
while True:
kwargs = {
"savingsPlanTypes": ["Database"],
"products": ["RDS"],
"serviceCodes": ["AmazonRDS"],
"filters": [
{"name": "region", "values": [region]},
{"name": "productDescription", "values": [engine]},
],
"maxResults": 1000,
}
if token:
kwargs["nextToken"] = token
resp = sp.describe_savings_plans_offering_rates(**kwargs)
rates.extend(resp.get("searchResults", []))
token = resp.get("nextToken")
if not token:
break
for rate_entry in rates:
offering = rate_entry.get("savingsPlanOffering", {})
dur = offering.get("durationSeconds", 0)
term_years = 3 if dur > 94_000_000 else 1
payment = offering.get("paymentOption", "")
try:
rate_val = float(rate_entry.get("rate", "0"))
except (ValueError, TypeError):
continue
if rate_val <= 0:
continue
usage = rate_entry.get("usageType", "")
unit = rate_entry.get("unit", "")
# Skip I/O-Optimized variants for consistency; main pricing uses Standard
if "IOOptimized" in usage:
continue
if unit == "ACU-Hr" and "ServerlessV2" in usage:
key = "ServerlessV2"
else:
m = re.match(r"InstanceUsage:db\.(\w+)\.(\w+)", usage)
if not m:
continue
family = m.group(1)
short_size = m.group(2)
size = _DSP_SIZE_MAP.get(short_size, short_size)
key = f"db.{family}.{size}"
entry = DSPRate(
usage_type=key,
term_years=term_years,
payment_option=payment,
rate_per_hour=round(rate_val, 6),
)
existing = result.get(key, [])
if not any(
e.term_years == entry.term_years and e.payment_option == entry.payment_option
for e in existing
):
result.setdefault(key, []).append(entry)
except Exception:
continue
return result
# ---------------------------------------------------------------------------
# Best-of selection (lowest effective monthly cost per term/category)
# ---------------------------------------------------------------------------
def best_ri(offerings: list[RIOffering], term_years: int) -> RIOffering | None:
candidates = [r for r in offerings if r.term_years == term_years]
if not candidates:
return None
return min(candidates, key=lambda r: r.effective_hourly)
def best_dsp(rates: list[DSPRate], term_years: int = 1) -> DSPRate | None:
candidates = [r for r in rates if r.term_years == term_years]
if not candidates:
return None
return min(candidates, key=lambda r: r.rate_per_hour)
# ---------------------------------------------------------------------------
# Comparison builder — single workload
# ---------------------------------------------------------------------------
def build_comparison(
instance_type: str,
num_instances: int,
region: str,
io_optimized: bool = False,
is_serverless: bool = False,
avg_acu: float = 0.0,
dsp_rates: dict[str, list[DSPRate]] | None = None,
) -> dict:
"""Compare on-demand, RI, and DSP for a single workload description."""
if dsp_rates is None:
dsp_rates = fetch_dsp_rates(region)
if is_serverless:
od_hourly = ACU_PRICE_IO_OPTIMIZED if io_optimized else ACU_PRICE_STANDARD
# For Serverless v2, "number of instances" is irrelevant — we price avg ACU continuously
units = avg_acu
od_monthly = od_hourly * units * HOURS_PER_MONTH
dsp_entry = best_dsp(dsp_rates.get("ServerlessV2", []))
dsp_monthly = dsp_entry.rate_per_hour * units * HOURS_PER_MONTH if dsp_entry else None
dsp_savings = (od_monthly - dsp_monthly) if dsp_monthly is not None else None
return {
"workload_type": "serverless_v2",
"avg_acu": avg_acu,
"io_optimized": io_optimized,
"on_demand": {
"hourly": od_hourly,
"monthly": round(od_monthly, 2),
},
"ri_1yr": None,
"ri_3yr": None,
"dsp_1yr": _format_dsp(dsp_entry, units, od_monthly) if dsp_entry else None,
"recommendation": _recommend_serverless(dsp_savings, od_monthly),
"notes": [
"Reserved Instances do not apply to Aurora Serverless v2.",
"DSP covers ACU-hours but bills the committed $/hr continuously, "
"even during auto-pause. Only commit to the steady baseline ACU.",
],
}
# Provisioned
family = _family_from_instance(instance_type)
od_hourly = get_on_demand_price(instance_type, region)
if io_optimized:
od_hourly *= IO_OPT_COMPUTE_MULTIPLIER
od_monthly = od_hourly * HOURS_PER_MONTH * num_instances
ri_offerings = fetch_ri_offerings(instance_type, region)
ri_1yr = best_ri(ri_offerings, 1)
ri_3yr = best_ri(ri_offerings, 3)
# I/O-Optimized RI coverage (AWS Compute Optimizer, verified): an I/O-Optimized
# instance is FULLY covered by Reserved Instances — no portion is forced to
# on-demand — but it "consumes 30% more normalized units per hour than Aurora
# Standard", i.e. it draws down RI capacity at 1.30×. So the effective RI cost is
# the (Standard-normalized) RI rate × 1.30. Equivalently: buy ~30% more normalized
# RI units to cover the same I/O-Optimized fleet.
# io-opt RI monthly = ri_rate × 1.30 × hours × N
def ri_adjusted_monthly(ri: RIOffering | None) -> float | None:
if ri is None:
return None
units = IO_OPT_COMPUTE_MULTIPLIER if io_optimized else 1.0
return ri.effective_hourly * units * HOURS_PER_MONTH * num_instances
ri_1yr_monthly = ri_adjusted_monthly(ri_1yr)
ri_3yr_monthly = ri_adjusted_monthly(ri_3yr)
dsp_entry = best_dsp(dsp_rates.get(instance_type, []))
dsp_monthly = dsp_entry.rate_per_hour * HOURS_PER_MONTH * num_instances if dsp_entry else None
dsp_eligible = family in _DSP_ELIGIBLE_FAMILIES
notes = []
if not dsp_eligible:
notes.append(
f"Database Savings Plans do not cover the {family} family. "
f"DSP requires r7g, r8g, r7i, or newer-gen Aurora-compatible families."
)
if io_optimized:
notes.append(
"Cluster is I/O-Optimized (30% compute premium). Both RI and DSP cover the "
"full I/O-Optimized instance price — I/O-Optimized consumes 30% more "
"normalized units per hour, so an RI draws down capacity at 1.30× (buy ~30% "
"more normalized RI units to fully cover the fleet); no portion is on-demand."
)
return {
"workload_type": "provisioned",
"instance_type": instance_type,
"num_instances": num_instances,
"io_optimized": io_optimized,
"on_demand": {
"hourly": round(od_hourly, 4),
"monthly": round(od_monthly, 2),
},
"ri_1yr": _format_ri(ri_1yr, ri_1yr_monthly, od_monthly, num_instances),
"ri_3yr": _format_ri(ri_3yr, ri_3yr_monthly, od_monthly, num_instances),
"dsp_1yr": (_format_dsp(dsp_entry, num_instances, od_monthly) if dsp_entry else None),
"recommendation": _recommend_provisioned(
od_monthly,
ri_1yr_monthly,
ri_3yr_monthly,
dsp_monthly,
dsp_eligible=dsp_eligible,
io_optimized=io_optimized,
),
"notes": notes,
}
def _format_ri(
ri: RIOffering | None, monthly: float | None, od_monthly: float, n: int
) -> dict | None:
if ri is None or monthly is None:
return None
savings = od_monthly - monthly
pct = (savings / od_monthly * 100) if od_monthly > 0 else 0
return {
"term_years": ri.term_years,
"payment_option": ri.payment_option,
"effective_hourly_per_instance": round(ri.effective_hourly, 4),
"upfront_total": round(ri.upfront_cost * n, 2),
"monthly": round(monthly, 2),
"savings_monthly": round(savings, 2),
"savings_pct": round(pct, 1),
}
def _format_dsp(dsp: DSPRate | None, units: float, od_monthly: float) -> dict | None:
if dsp is None:
return None
monthly = dsp.rate_per_hour * HOURS_PER_MONTH * units
savings = od_monthly - monthly
pct = (savings / od_monthly * 100) if od_monthly > 0 else 0
return {
"term_years": dsp.term_years,
"payment_option": dsp.payment_option,
"rate_per_hour": round(dsp.rate_per_hour, 4),
"monthly": round(monthly, 2),
"savings_monthly": round(savings, 2),
"savings_pct": round(pct, 1),
}
def _recommend_provisioned(
od: float,
ri_1yr: float | None,
ri_3yr: float | None,
dsp: float | None,
dsp_eligible: bool,
io_optimized: bool,
) -> dict:
options = []
if ri_1yr is not None:
options.append(("1yr RI", ri_1yr))
if ri_3yr is not None:
options.append(("3yr RI", ri_3yr))
if dsp is not None:
options.append(("1yr DSP", dsp))
if not options:
return {
"best_option": "on_demand",
"reason": "No RI or DSP offerings available for this instance type/region.",
}
best_label, best_cost = min(options, key=lambda x: x[1])
savings = od - best_cost
pct = (savings / od * 100) if od > 0 else 0
reasons = [
f"{best_label} is the lowest-cost option, saving ${savings:.0f}/mo ({pct:.0f}%) vs on-demand."
]
if best_label == "3yr RI":
reasons.append(
"Best fit for steady 24/7 workloads you're confident will stay on this instance family for 3 years."
)
elif best_label == "1yr DSP":
reasons.append(
"Offers flexibility — covers any eligible Aurora instance family in the account, including future upgrades."
)
if io_optimized:
reasons.append(
"DSP is particularly attractive for I/O-Optimized clusters since it covers the full rate."
)
elif best_label == "1yr RI":
reasons.append(
"Shorter commitment than 3yr, useful when instance-family migration is on the horizon."
)
if not dsp_eligible and dsp is None:
reasons.append(
"DSP is not available for this instance family, so RI is the only commitment option."
)
return {
"best_option": best_label,
"best_monthly_cost": round(best_cost, 2),
"savings_vs_on_demand": round(savings, 2),
"savings_pct": round(pct, 1),
"reason": " ".join(reasons),
}
def _recommend_serverless(dsp_savings: float | None, od_monthly: float) -> dict:
if dsp_savings is None:
return {
"best_option": "on_demand",
"reason": "No Database Savings Plan rates available for Serverless v2 ACU in this region.",
}
if dsp_savings <= 0:
return {
"best_option": "on_demand",
"reason": "DSP would not save money at the specified average ACU.",
}
pct = (dsp_savings / od_monthly * 100) if od_monthly > 0 else 0
return {
"best_option": "1yr DSP",
"savings_vs_on_demand": round(dsp_savings, 2),
"savings_pct": round(pct, 1),
"reason": (
f"1yr DSP saves ${dsp_savings:.0f}/mo ({pct:.0f}%). "
"Size the commitment to your steady baseline ACU — DSP bills the committed $/hr "
"continuously, even during idle periods."
),
}
# ---------------------------------------------------------------------------
# Live cluster analysis
# ---------------------------------------------------------------------------
def _is_empty_cluster(cluster: dict) -> bool:
"""Skip clusters with no compute to analyze.
An Aurora cluster with no DB instances has no compute cost, so RI and DSP
commitment analysis doesn't apply. Typical cases: the last writer/reader
instance was deleted, paused/stopped clusters, or clusters mid-migration.
"""
return len(cluster.get("DBClusterMembers", [])) == 0
def analyze_cluster_live(cluster_id: str, region: str) -> dict:
import boto3
rds = boto3.client("rds", region_name=region)
try:
resp = rds.describe_db_clusters(DBClusterIdentifier=cluster_id)
except Exception as e:
return {"cluster_id": cluster_id, "error": str(e)}
clusters = resp.get("DBClusters", [])
if not clusters:
return {"cluster_id": cluster_id, "error": "cluster not found"}
cluster = clusters[0]
storage_type = cluster.get("StorageType", "aurora")
io_optimized = storage_type == "aurora-iopt1"
engine = cluster.get("Engine", "")
# Guardrail: skip clusters with no DB instances (last instance deleted, paused, mid-migration, etc.)
if _is_empty_cluster(cluster):
return {
"cluster_id": cluster_id,
"engine": engine,
"engine_version": cluster.get("EngineVersion", ""),
"storage_type": storage_type,
"skipped": True,
"reason": (
"Cluster has no DB instances — no compute to price. "
"RI and DSP commitment analysis does not apply. "
"This typically indicates the last instance was deleted, a paused "
"cluster, or a cluster mid-migration."
),
}
# Identify instances
member_ids = [m["DBInstanceIdentifier"] for m in cluster.get("DBClusterMembers", [])]
type_counts: dict[str, int] = {}
serverless_instances = 0
for mid in member_ids:
try:
iresp = rds.describe_db_instances(DBInstanceIdentifier=mid)
for inst in iresp.get("DBInstances", []):
itype = inst.get("DBInstanceClass", "")
if itype == "db.serverless":
serverless_instances += 1
else:
type_counts[itype] = type_counts.get(itype, 0) + 1
except Exception:
continue
# Prefetch DSP rates once for this cluster analysis
dsp_rates = fetch_dsp_rates(region)
sub_workloads = []
for itype, count in type_counts.items():
sub_workloads.append(
build_comparison(
instance_type=itype,
num_instances=count,
region=region,
io_optimized=io_optimized,
dsp_rates=dsp_rates,
)
)
if serverless_instances > 0:
# Without observed ACU metrics, we can't price serverless exactly — note it
sub_workloads.append(
{
"workload_type": "serverless_v2",
"instance_count": serverless_instances,
"note": "Serverless v2 instances detected. Re-run with 'offline --serverless "
"--avg-acu <N>' using your observed average ACU (from CloudWatch "
"ServerlessDatabaseCapacity metric) for a precise DSP estimate.",
"io_optimized": io_optimized,
}
)
return {
"cluster_id": cluster_id,
"engine": engine,
"storage_type": storage_type,
"io_optimized": io_optimized,
"instance_mix": {**type_counts, "serverless": serverless_instances},
"workloads": sub_workloads,
}
def list_clusters(region: str) -> list[str]:
import boto3
rds = boto3.client("rds", region_name=region)
names = []
for page in rds.get_paginator("describe_db_clusters").paginate():
for c in page.get("DBClusters", []):
if c.get("Engine", "").startswith("aurora"):
names.append(c["DBClusterIdentifier"])
return names
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def _format_table_single(result: dict) -> str:
lines = []
lines.append("=" * 72)
if result.get("workload_type") == "serverless_v2":
lines.append(f"Aurora Serverless v2 Commitment Pricing")
lines.append(
f" Avg ACU: {result.get('avg_acu', 0):.1f} "
f"I/O-Optimized: {result.get('io_optimized', False)}"
)
else:
lines.append(
f"Aurora Commitment Pricing — "
f"{result.get('num_instances', 1)}× {result.get('instance_type', '?')}"
)
if result.get("io_optimized"):
lines.append(" Storage: I/O-Optimized (30% compute premium applied)")
lines.append("=" * 72)
od_monthly = result["on_demand"]["monthly"]
lines.append("")
lines.append(f" {'Option':<28} {'Monthly':>12} {'Savings':>12} {'Upfront':>12} Term")
lines.append(" " + "-" * 70)
lines.append(f" {'On-Demand':<28} ${od_monthly:>11,.0f} {'—':>12} {'$0':>12} —")
for key, label, term_hint in (
("ri_1yr", "1yr RI", "1 year"),
("ri_3yr", "3yr RI", "3 years"),
("dsp_1yr", "1yr DSP", "1 year"),
):
entry = result.get(key)
if not entry:
continue
payment = entry.get("payment_option", "")
display = f"{label} ({payment})" if payment else label
monthly = entry.get("monthly", 0)
savings = entry.get("savings_monthly", 0)
pct = entry.get("savings_pct", 0)
upfront = entry.get("upfront_total", 0)
savings_str = f"${savings:,.0f} ({pct:.0f}%)" if savings else "—"
upfront_str = f"${upfront:,.0f}" if upfront else "$0"
lines.append(
f" {display:<28} ${monthly:>11,.0f} {savings_str:>12} {upfront_str:>12} {term_hint}"
)
rec = result.get("recommendation", {})
lines.append("")
lines.append(f" Recommendation: {rec.get('best_option', '?')}")
lines.append(f" {rec.get('reason', '')}")
notes = result.get("notes", [])
if notes:
lines.append("")
for n in notes:
lines.append(f" Note: {n}")
lines.append("=" * 72)
return "\n".join(lines)
def _format_cluster(result: dict) -> str:
lines = []
lines.append(
f"Cluster: {result.get('cluster_id', '?')} "
f"({result.get('engine', '?')}) "
f"storage_type={result.get('storage_type', '?')}"
)
workloads = result.get("workloads", [])
for wl in workloads:
if wl.get("workload_type") == "serverless_v2" and "note" in wl:
lines.append("")
lines.append(f" [Serverless v2 — {wl.get('instance_count', 0)} instance(s)]")
lines.append(f" {wl['note']}")
continue
lines.append("")
lines.append(_format_table_single(wl))
return "\n".join(lines)
def _format_fleet(output: dict) -> str:
lines = []
lines.append(f"Region: {output['region']}")
lines.append("")
total_od = 0.0
total_best = 0.0
for cluster in output["clusters"]:
if "error" in cluster:
lines.append(f"{cluster['cluster_id']}: ERROR {cluster['error']}")
continue
for wl in cluster.get("workloads", []):
if wl.get("workload_type") != "provisioned":
continue
od = wl["on_demand"]["monthly"]
best = wl.get("recommendation", {}).get("best_monthly_cost", od)
total_od += od
total_best += best
lines.append(f" Fleet monthly on-demand: ${total_od:,.0f}")
lines.append(f" With best commitments: ${total_best:,.0f}")
savings = total_od - total_best
pct = (savings / total_od * 100) if total_od > 0 else 0
lines.append(f" Fleet savings opportunity: ${savings:,.0f}/mo ({pct:.0f}%)")
lines.append("")
for cluster in output["clusters"]:
if "error" in cluster:
continue
lines.append("")
lines.append(_format_cluster(cluster))
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Aurora RI & Database Savings Plan estimator (read-only)"
)
parser.add_argument("--region", default="us-east-1")
parser.add_argument("--format", choices=["json", "table"], default="json")
parser.add_argument("--cluster", help="Analyze a single cluster by identifier")
parser.add_argument(
"--all", action="store_true", help="Analyze all Aurora clusters in the region"
)
sub = parser.add_subparsers(dest="mode")
off = sub.add_parser("offline", help="Use user-supplied workload description")
off.add_argument("--instance", help="Instance type (e.g., db.r7g.2xlarge)")
off.add_argument("--num-instances", type=int, default=1)
off.add_argument(
"--io-optimized", action="store_true", help="Workload uses Aurora I/O-Optimized storage"
)
off.add_argument(
"--serverless", action="store_true", help="Serverless v2 workload — requires --avg-acu"
)
off.add_argument(
"--avg-acu", type=float, default=0.0, help="Average ACU for serverless workload"
)
off.add_argument("--region", default="us-east-1")
off.add_argument("--format", choices=["json", "table"], default="json")
args = parser.parse_args()
if args.mode == "offline":
if args.serverless:
if args.avg_acu <= 0:
print("ERROR: --serverless requires --avg-acu > 0", file=sys.stderr)
sys.exit(2)
result = build_comparison(
instance_type="",
num_instances=0,
region=args.region,
io_optimized=args.io_optimized,
is_serverless=True,
avg_acu=args.avg_acu,
)
else:
if not args.instance:
print("ERROR: offline mode requires --instance (or --serverless)", file=sys.stderr)
sys.exit(2)
result = build_comparison(
instance_type=args.instance,
num_instances=args.num_instances,
region=args.region,
io_optimized=args.io_optimized,
)
if args.format == "json":
print(json.dumps(result, indent=2, default=str))
else:
print(_format_table_single(result))
return
# Live modes require boto3
try:
import boto3 # noqa: F401
except ImportError:
print(
"ERROR: boto3 required for live AWS analysis. Use the 'offline' subcommand.",
file=sys.stderr,
)
sys.exit(2)
if args.all:
cluster_ids = list_clusters(args.region)
results = [analyze_cluster_live(cid, args.region) for cid in cluster_ids]
output = {"region": args.region, "cluster_count": len(results), "clusters": results}
if args.format == "json":
print(json.dumps(output, indent=2, default=str))
else:
print(_format_fleet(output))
return
if args.cluster:
result = analyze_cluster_live(args.cluster, args.region)
if args.format == "json":
print(json.dumps(result, indent=2, default=str))
else:
print(_format_cluster(result))
return
parser.print_help()
if __name__ == "__main__":
main()
scripts/io_optimized_analyzer.py
"""Aurora I/O-Optimized vs Standard cost analyzer.
Assesses whether Aurora I/O-Optimized is a better fit than Aurora Standard for
one cluster, all clusters in a region, or offline user-supplied numbers.
Decision: I/O-Optimized is recommended when it actually lowers total monthly cost
(eliminated I/O charges exceed the compute + storage premium). The "I/O >= 25% of
total spend" figure is a useful rule-of-thumb, but the recommendation is gated on the
computed dollar savings, not the percentage alone (they can diverge near breakeven).
Same logic across Aurora engines (engine-neutral pricing math).
Pricing math and breakeven logic cross-checked with AWS documentation.
Usage:
python io_optimized_analyzer.py --cluster my-cluster --region us-east-1
python io_optimized_analyzer.py --all --region us-east-1 --days 14
python io_optimized_analyzer.py offline --instance db.r6g.2xlarge \\
--num-instances 2 --storage-gib 800 --monthly-io-millions 1200
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sys
from typing import Any
# ---------------------------------------------------------------------------
# Pricing constants (us-east-1). Overridden by live API when available.
# ---------------------------------------------------------------------------
STORAGE_STANDARD_PER_GIB = 0.10 # $/GiB-month, Standard
STORAGE_IO_OPT_PER_GIB = 0.225 # $/GiB-month, I/O-Optimized
IO_COST_PER_MILLION = 0.20 # $/million I/O requests, Standard only
IO_OPT_COMPUTE_MULTIPLIER = 1.30 # 30% compute premium on I/O-Optimized
IO_OPT_BREAKEVEN_PCT = 25.0
HOURS_PER_MONTH = 730
MIN_VIABLE_DAYS = 7.0
# Static instance hourly prices (us-east-1, Standard, Aurora MySQL).
# Aurora MySQL and PostgreSQL share these rates; I/O-Optimized is derived via the multiplier.
_STATIC_INSTANCE_PRICES = {
"db.t3.medium": 0.082,
"db.t3.large": 0.164,
"db.t4g.medium": 0.073,
"db.t4g.large": 0.146,
"db.r5.large": 0.290,
"db.r5.xlarge": 0.580,
"db.r5.2xlarge": 1.160,
"db.r5.4xlarge": 2.320,
"db.r5.8xlarge": 4.640,
"db.r5.12xlarge": 6.960,
"db.r5.16xlarge": 9.280,
"db.r5.24xlarge": 13.920,
"db.r6g.large": 0.260,
"db.r6g.xlarge": 0.519,
"db.r6g.2xlarge": 1.038,
"db.r6g.4xlarge": 2.076,
"db.r6g.8xlarge": 4.152,
"db.r6g.12xlarge": 6.228,
"db.r6g.16xlarge": 8.304,
"db.r7g.large": 0.276,
"db.r7g.xlarge": 0.553,
"db.r7g.2xlarge": 1.106,
"db.r7g.4xlarge": 2.211,
"db.r7g.8xlarge": 4.422,
"db.r7g.12xlarge": 6.633,
"db.r7g.16xlarge": 8.844,
"db.r8g.large": 0.276,
"db.r8g.xlarge": 0.552,
"db.r8g.2xlarge": 1.104,
"db.r8g.4xlarge": 2.208,
"db.r8g.8xlarge": 4.416,
"db.r8g.12xlarge": 6.624,
"db.r8g.16xlarge": 8.832,
"db.r8g.24xlarge": 13.248,
"db.r8g.48xlarge": 26.496,
}
_REGION_NAMES = {
"us-east-1": "US East (N. Virginia)",
"us-east-2": "US East (Ohio)",
"us-west-1": "US West (N. California)",
"us-west-2": "US West (Oregon)",
"eu-west-1": "EU (Ireland)",
"eu-west-2": "EU (London)",
"eu-central-1": "EU (Frankfurt)",
"eu-north-1": "EU (Stockholm)",
"ap-southeast-1": "Asia Pacific (Singapore)",
"ap-southeast-2": "Asia Pacific (Sydney)",
"ap-northeast-1": "Asia Pacific (Tokyo)",
"ap-south-1": "Asia Pacific (Mumbai)",
"ca-central-1": "Canada (Central)",
"sa-east-1": "South America (Sao Paulo)",
}
INSTANCE_PRICES = dict(_STATIC_INSTANCE_PRICES)
_pricing_source: dict[str, Any] = {"source": "static_fallback", "region": "us-east-1"}
# ---------------------------------------------------------------------------
# Live pricing (best-effort)
# ---------------------------------------------------------------------------
def refresh_pricing(region: str) -> dict:
"""Try to fetch live instance + storage + I/O pricing. Silent fallback on failure."""
global INSTANCE_PRICES, STORAGE_STANDARD_PER_GIB, STORAGE_IO_OPT_PER_GIB
global IO_COST_PER_MILLION, _pricing_source
location = _REGION_NAMES.get(region)
if not location:
_pricing_source = {
"source": "static_fallback",
"region": region,
"note": f"Region {region} not mapped; using us-east-1 defaults",
}
return _pricing_source
try:
import boto3
except ImportError:
_pricing_source = {
"source": "static_fallback",
"region": region,
"note": "boto3 not installed",
}
return _pricing_source
try:
pricing = boto3.client("pricing", region_name="us-east-1")
live_instances = 0
filters = [
{"Type": "TERM_MATCH", "Field": "databaseEngine", "Value": "Aurora MySQL"},
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
{"Type": "TERM_MATCH", "Field": "deploymentOption", "Value": "Single-AZ"},
{"Type": "TERM_MATCH", "Field": "termType", "Value": "OnDemand"},
]
for page in pricing.get_paginator("get_products").paginate(
ServiceCode="AmazonRDS", Filters=filters
):
for item_json in page["PriceList"]:
item = json.loads(item_json) if isinstance(item_json, str) else item_json
attrs = item.get("product", {}).get("attributes", {})
itype = attrs.get("instanceType", "")
if not itype.startswith("db."):
continue
if "IOOptimized" in attrs.get("usagetype", ""):
continue
for term in item.get("terms", {}).get("OnDemand", {}).values():
for dim in term.get("priceDimensions", {}).values():
try:
price = float(dim.get("pricePerUnit", {}).get("USD", "0"))
except (ValueError, TypeError):
continue
if price > 0:
INSTANCE_PRICES[itype] = price
live_instances += 1
# Storage + I/O pricing
storage_filters = [
{"Type": "TERM_MATCH", "Field": "databaseEngine", "Value": "Aurora MySQL"},
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
{"Type": "TERM_MATCH", "Field": "productFamily", "Value": "Database Storage"},
{"Type": "TERM_MATCH", "Field": "termType", "Value": "OnDemand"},
]
for page in pricing.get_paginator("get_products").paginate(
ServiceCode="AmazonRDS", Filters=storage_filters
):
for item_json in page["PriceList"]:
item = json.loads(item_json) if isinstance(item_json, str) else item_json
attrs = item.get("product", {}).get("attributes", {})
usage = attrs.get("usagetype", "")
for term in item.get("terms", {}).get("OnDemand", {}).values():
for dim in term.get("priceDimensions", {}).values():
try:
price = float(dim.get("pricePerUnit", {}).get("USD", "0"))
except (ValueError, TypeError):
continue
if price <= 0:
continue
if "IOOptimized" in usage:
STORAGE_IO_OPT_PER_GIB = price
elif "Aurora:StorageUsage" in usage:
STORAGE_STANDARD_PER_GIB = price
elif "Aurora:StorageIOUsage" in usage:
IO_COST_PER_MILLION = price * 1_000_000 # per-request -> per-million
_pricing_source = {
"source": "live",
"region": region,
"live_instances": live_instances,
"storage_standard": STORAGE_STANDARD_PER_GIB,
"storage_io_opt": STORAGE_IO_OPT_PER_GIB,
"io_per_million": IO_COST_PER_MILLION,
}
except Exception as e:
_pricing_source = {"source": "static_fallback", "region": region, "error": str(e)}
return _pricing_source
# ---------------------------------------------------------------------------
# Core calculation (shared by live and offline paths)
# ---------------------------------------------------------------------------
def compute_comparison(
compute_monthly: float,
storage_gib: float,
monthly_io_millions: float,
) -> dict:
"""Return Standard vs I/O-Optimized comparison and recommendation."""
storage_std = storage_gib * STORAGE_STANDARD_PER_GIB
io_cost = monthly_io_millions * IO_COST_PER_MILLION
total_std = compute_monthly + storage_std + io_cost
compute_io_opt = compute_monthly * IO_OPT_COMPUTE_MULTIPLIER
storage_io_opt = storage_gib * STORAGE_IO_OPT_PER_GIB
total_io_opt = compute_io_opt + storage_io_opt
io_pct = (io_cost / total_std * 100) if total_std > 0 else 0
savings = total_std - total_io_opt
# Drive the recommendation off the ACTUAL dollar savings, not the 25% heuristic
# alone — near the breakeven boundary the two diverge, and gating purely on the
# threshold can recommend I/O-Optimized while it actually costs more (and print a
# nonsensical "saves $-N/mo"). The 25% rule is a useful rule-of-thumb but the real
# decision is whether the I/O charges eliminated exceed the compute+storage premium.
threshold_note = (
f"I/O is {io_pct:.0f}% of total cost "
f"({'≥' if io_pct >= IO_OPT_BREAKEVEN_PCT else 'below '}{IO_OPT_BREAKEVEN_PCT:.0f}% rule-of-thumb)."
)
if savings > 0:
rec = "io_optimized"
reason = f"{threshold_note} I/O-Optimized saves ${savings:.0f}/mo."
else:
rec = "standard"
reason = f"{threshold_note} I/O-Optimized would cost ${abs(savings):.0f}/mo more."
return {
"standard": {
"compute_monthly": round(compute_monthly, 2),
"storage_monthly": round(storage_std, 2),
"io_monthly": round(io_cost, 2),
"total_monthly": round(total_std, 2),
},
"io_optimized": {
"compute_monthly": round(compute_io_opt, 2),
"storage_monthly": round(storage_io_opt, 2),
"io_monthly": 0.0,
"total_monthly": round(total_io_opt, 2),
},
"monthly_io_millions": round(monthly_io_millions, 1),
"storage_gib": round(storage_gib, 1),
"io_cost_pct_of_total": round(io_pct, 1),
"savings_with_io_opt": round(savings, 2),
"recommendation": rec,
"reason": reason,
}
def data_quality_tag(days: float) -> str:
if days < 3:
return "insufficient"
if days < 7:
return "short"
if days < 14:
return "adequate"
return "good"
# ---------------------------------------------------------------------------
# Live AWS path
# ---------------------------------------------------------------------------
def _sum_metric(cw, cluster_id: str, metric: str, start: dt.datetime, end: dt.datetime) -> float:
"""Sum a CloudWatch metric over the window. Returns total."""
resp = cw.get_metric_statistics(
Namespace="AWS/RDS",
MetricName=metric,
Dimensions=[{"Name": "DBClusterIdentifier", "Value": cluster_id}],
StartTime=start,
EndTime=end,
Period=3600,
Statistics=["Sum"],
)
return sum(dp.get("Sum", 0) for dp in resp.get("Datapoints", []))
def _avg_metric(cw, cluster_id: str, metric: str, start: dt.datetime, end: dt.datetime) -> float:
"""Average a CloudWatch metric over the window."""
resp = cw.get_metric_statistics(
Namespace="AWS/RDS",
MetricName=metric,
Dimensions=[{"Name": "DBClusterIdentifier", "Value": cluster_id}],
StartTime=start,
EndTime=end,
Period=3600,
Statistics=["Average"],
)
dps = resp.get("Datapoints", [])
return (sum(dp.get("Average", 0) for dp in dps) / len(dps)) if dps else 0.0
def _is_empty_cluster(cluster: dict) -> bool:
"""Skip clusters with no compute to analyze.
An Aurora cluster with no DB instances has no compute cost to compare. The
genuine cause is a cluster whose last writer/reader instance was deleted.
Note: an auto-paused (scale-to-zero) Aurora
serverless instance still appears in DBClusterMembers and is analyzable, so
it is NOT an empty cluster. The cost comparison doesn't apply here — skip.
"""
return len(cluster.get("DBClusterMembers", [])) == 0
def analyze_cluster_live(cluster_id: str, region: str, days: int) -> dict:
"""Analyze a single cluster using live AWS APIs."""
import boto3
rds = boto3.client("rds", region_name=region)
cw = boto3.client("cloudwatch", region_name=region)
# Cluster metadata
resp = rds.describe_db_clusters(DBClusterIdentifier=cluster_id)
clusters = resp.get("DBClusters", [])
if not clusters:
return {"cluster_id": cluster_id, "error": "cluster not found"}
cluster = clusters[0]
current_storage_type = cluster.get("StorageType", "aurora") # 'aurora' or 'aurora-iopt1'
engine = cluster.get("Engine", "")
# Guardrail: skip clusters with no DB instances (last instance deleted / paused)
if _is_empty_cluster(cluster):
return {
"cluster_id": cluster_id,
"engine": engine,
"engine_version": cluster.get("EngineVersion", ""),
"current_storage_type": current_storage_type,
"skipped": True,
"reason": (
"Cluster has no DB instances — no compute to analyze. "
"This usually means the cluster's last writer/reader instance "
"was deleted. The Standard vs I/O-Optimized comparison does not "
"apply until the cluster has a running instance."
),
}
# Get instance types in the cluster
member_ids = [m["DBInstanceIdentifier"] for m in cluster.get("DBClusterMembers", [])]
compute_monthly = 0.0
instance_summary = []
compute_warnings = []
for mid in member_ids:
try:
inst_resp = rds.describe_db_instances(DBInstanceIdentifier=mid)
for inst in inst_resp.get("DBInstances", []):
itype = inst.get("DBInstanceClass", "")
# Aurora Serverless v2 (db.serverless) has no fixed hourly rate — it bills
# per-ACU-hour from a CloudWatch metric, not from INSTANCE_PRICES. Counting it
# at $0 would silently understate compute and skew the I/O-cost percentage, so
# exclude it and flag the estimate as partial rather than emit a wrong number.
if itype == "db.serverless":
instance_summary.append(
{"id": mid, "type": itype, "note": "serverless_excluded"}
)
compute_warnings.append(
f"{mid} is Aurora Serverless v2 (db.serverless) — its ACU-based compute "
"cost is not included (it has no fixed hourly rate); the Standard vs "
"I/O-Optimized compute figures below cover provisioned instances only."
)
continue
price = INSTANCE_PRICES.get(itype, 0.0)
if price == 0.0:
# Unknown/unpriced provisioned type — don't silently add $0.
instance_summary.append({"id": mid, "type": itype, "note": "unknown_price"})
compute_warnings.append(
f"{mid} ({itype}) has no known hourly price in the static/live table — "
"excluded from the compute estimate; results are partial."
)
continue
compute_monthly += price * HOURS_PER_MONTH
instance_summary.append({"id": mid, "type": itype, "price_hr": price})
except Exception as e:
instance_summary.append({"id": mid, "error": str(e)})
# CloudWatch window
end = dt.datetime.now(dt.timezone.utc).replace(minute=0, second=0, microsecond=0)
start = end - dt.timedelta(days=days)
observed_hours = days * 24
read_io = _sum_metric(cw, cluster_id, "VolumeReadIOPs", start, end)
write_io = _sum_metric(cw, cluster_id, "VolumeWriteIOPs", start, end)
total_io = read_io + write_io
# Extrapolate to 730-hour month
monthly_io = (total_io / observed_hours * HOURS_PER_MONTH) if observed_hours > 0 else 0
monthly_io_millions = monthly_io / 1_000_000
# Storage (average)
avg_bytes = _avg_metric(cw, cluster_id, "VolumeBytesUsed", start, end)
storage_gib = avg_bytes / (1024**3) # Aurora bills actual usage; no fixed minimum
comparison = compute_comparison(compute_monthly, storage_gib, monthly_io_millions)
result = {
"cluster_id": cluster_id,
"engine": engine,
"current_storage_type": current_storage_type,
"instances": instance_summary,
"lookback_days": days,
"data_quality": data_quality_tag(days),
"observed_io_total": int(total_io),
**comparison,
}
if compute_warnings:
result["compute_partial"] = True
result["compute_warnings"] = compute_warnings
return result
def list_clusters(region: str) -> list[str]:
import boto3
rds = boto3.client("rds", region_name=region)
names = []
for page in rds.get_paginator("describe_db_clusters").paginate():
for c in page.get("DBClusters", []):
if c.get("Engine", "").startswith("aurora"):
names.append(c["DBClusterIdentifier"])
return names
# ---------------------------------------------------------------------------
# Offline path (no AWS calls)
# ---------------------------------------------------------------------------
def analyze_offline(
instance: str,
num_instances: int,
storage_gib: float,
monthly_io_millions: float,
) -> dict:
if instance not in INSTANCE_PRICES:
return {
"error": f"Unknown instance type: {instance}. "
f"Supported: {', '.join(sorted(INSTANCE_PRICES))}"
}
compute_monthly = INSTANCE_PRICES[instance] * HOURS_PER_MONTH * num_instances
comparison = compute_comparison(compute_monthly, storage_gib, monthly_io_millions)
return {
"cluster_id": "offline-input",
"instance_type": instance,
"num_instances": num_instances,
"data_quality": "user_supplied",
**comparison,
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Aurora I/O-Optimized vs Standard cost analyzer")
parser.add_argument("--region", default="us-east-1")
parser.add_argument(
"--days", type=int, default=14, help="CloudWatch lookback window (default 14)"
)
parser.add_argument("--format", choices=["json", "table"], default="json")
# Modes are positional/optional
parser.add_argument("--cluster", help="Analyze a single cluster by identifier")
parser.add_argument(
"--all", action="store_true", help="Analyze all Aurora clusters in the region"
)
sub = parser.add_subparsers(dest="mode")
off = sub.add_parser("offline", help="Use user-supplied numbers, no AWS calls")
off.add_argument("--instance", required=True)
off.add_argument("--num-instances", type=int, default=1)
off.add_argument("--storage-gib", type=float, required=True)
off.add_argument("--monthly-io-millions", type=float, required=True)
# --region / --format are already defined on the main parser. Re-declare them on
# the offline subparser so they are ALSO accepted after the subcommand, but with
# SUPPRESSed defaults so a copy doesn't clobber a value passed before 'offline';
# the real default is resolved once, post-parse, below.
off.add_argument("--region", default=argparse.SUPPRESS)
off.add_argument("--format", choices=["json", "table"], default=argparse.SUPPRESS)
args = parser.parse_args()
if not hasattr(args, "region") or args.region is None:
args.region = "us-east-1"
if not hasattr(args, "format") or args.format is None:
args.format = "json"
# Offline mode
if args.mode == "offline":
# Still attempt to refresh pricing so regional factors can apply
refresh_pricing(args.region)
result = analyze_offline(
args.instance, args.num_instances, args.storage_gib, args.monthly_io_millions
)
result["pricing_source"] = _pricing_source
_emit(result, args.format)
return
# Live modes require boto3
try:
import boto3 # noqa: F401
except ImportError:
print(
"ERROR: boto3 required for live AWS analysis. Install boto3 or use the 'offline' subcommand.",
file=sys.stderr,
)
sys.exit(2)
refresh_pricing(args.region)
if args.all:
cluster_ids = list_clusters(args.region)
if not cluster_ids:
print(json.dumps({"status": "ok", "region": args.region, "clusters": []}, indent=2))
return
results = [analyze_cluster_live(cid, args.region, args.days) for cid in cluster_ids]
summary = _fleet_summary(results)
output = {
"region": args.region,
"pricing_source": _pricing_source,
"summary": summary,
"clusters": results,
}
_emit(output, args.format, fleet=True)
return
if args.cluster:
result = analyze_cluster_live(args.cluster, args.region, args.days)
result["pricing_source"] = _pricing_source
_emit(result, args.format)
return
parser.print_help()
def _fleet_summary(results: list[dict]) -> dict:
# Exclude errored and skipped clusters (no-instance) from dollar totals
analyzable = [r for r in results if "error" not in r and not r.get("skipped")]
skipped = [r for r in results if r.get("skipped")]
total_std = sum(r.get("standard", {}).get("total_monthly", 0) for r in analyzable)
total_io_opt = sum(r.get("io_optimized", {}).get("total_monthly", 0) for r in analyzable)
switch_wins = [r["cluster_id"] for r in analyzable if r.get("recommendation") == "io_optimized"]
return {
"cluster_count": len(results),
"analyzable_count": len(analyzable),
"skipped_count": len(skipped),
"skipped_clusters": [
{"cluster_id": r["cluster_id"], "reason": r.get("reason", "")} for r in skipped
],
"clusters_that_should_switch": switch_wins,
"current_monthly_total_standard": round(total_std, 2),
"if_all_on_io_optimized_monthly": round(total_io_opt, 2),
"optimal_savings_monthly": round(
sum(max(0, r.get("savings_with_io_opt", 0)) for r in analyzable),
2,
),
}
def _emit(result: dict, fmt: str, fleet: bool = False) -> None:
if fmt == "json":
print(json.dumps(result, indent=2, default=str))
return
# Table format
if fleet:
s = result["summary"]
print(
f"Region: {result['region']} Clusters: {s['cluster_count']} "
f"(analyzable: {s['analyzable_count']}, skipped: {s['skipped_count']})"
)
print(f" Current (Standard): ${s['current_monthly_total_standard']:.0f}/mo")
print(f" All on I/O-Optimized: ${s['if_all_on_io_optimized_monthly']:.0f}/mo")
print(f" Optimal (switch winners): saves ${s['optimal_savings_monthly']:.0f}/mo")
print(f" Clusters to switch: {', '.join(s['clusters_that_should_switch']) or '(none)'}")
if s.get("skipped_clusters"):
print(f" Skipped (not applicable):")
for sc in s["skipped_clusters"]:
print(f" - {sc['cluster_id']}: {sc['reason'][:80]}")
print()
print(f"{'Cluster':<30} {'I/O %':>6} {'Std $/mo':>10} {'IOOpt $/mo':>12} {'Rec':>14}")
print("-" * 76)
for r in result["clusters"]:
if "error" in r:
print(f"{r['cluster_id']:<30} ERROR: {r['error']}")
continue
if r.get("skipped"):
print(
f"{r['cluster_id']:<30} {'—':>6} {'—':>10} {'—':>12} {'skipped (no instances)':>22}"
)
continue
print(
f"{r['cluster_id']:<30} {r['io_cost_pct_of_total']:>5.0f}% "
f"{r['standard']['total_monthly']:>10.0f} "
f"{r['io_optimized']['total_monthly']:>12.0f} "
f"{r['recommendation']:>14}"
)
return
# Single cluster
r = result
if r.get("skipped"):
print(f"Cluster: {r.get('cluster_id', '?')}")
print(f" Engine: {r.get('engine', '?')} {r.get('engine_version', '')}")
print(f" Status: SKIPPED — not applicable")
print(f" {r.get('reason', '')}")
return
print(f"Cluster: {r.get('cluster_id', '?')} ({r.get('data_quality', '?')} data)")
print(f" Current storage type: {r.get('current_storage_type', '?')}")
print(f" Monthly I/O: {r.get('monthly_io_millions', 0):.0f}M requests")
print(f" Storage: {r.get('storage_gib', 0):.0f} GiB")
print()
std = r["standard"]
ioo = r["io_optimized"]
print(f" {'Component':<12} {'Standard':>12} {'I/O-Optimized':>15}")
print(f" {'Compute':<12} {std['compute_monthly']:>12.0f} {ioo['compute_monthly']:>15.0f}")
print(f" {'Storage':<12} {std['storage_monthly']:>12.0f} {ioo['storage_monthly']:>15.0f}")
print(f" {'I/O':<12} {std['io_monthly']:>12.0f} {ioo['io_monthly']:>15.0f}")
print(f" {'Total':<12} {std['total_monthly']:>12.0f} {ioo['total_monthly']:>15.0f}")
print()
print(f" I/O cost: {r['io_cost_pct_of_total']:.0f}% of total")
print(f" Recommendation: {r['recommendation'].upper()}")
print(f" {r['reason']}")
if __name__ == "__main__":
main()
SKILL.md
---
name: amazon-aurora-mysql
description: >-
Amazon Aurora MySQL — creates, modifies, and advises on Aurora MySQL clusters specifically
(MySQL-compatible engine, Aurora serverless, parallel query). Trigger for Aurora
MySQL cluster operations, ACU sizing, I/O-Optimized storage, commitment pricing,
or MySQL upgrade planning. Aurora MySQL uses full (VPC-based) configuration — express
configuration is PostgreSQL-only. For Aurora PostgreSQL, use amazon-aurora-postgresql
instead. Contains safety guardrails and response templates that override defaults.
version: 1
---
# Amazon Aurora MySQL
A modular toolkit for **Aurora MySQL** organized as a registry of sub-skills. Each sub-skill handles one domain of Aurora MySQL work. The router matches user intent to the right sub-skill, then loads only the references needed. (For Aurora PostgreSQL — and its express-configuration quick-start — use the `amazon-aurora-postgresql` skill.)
## Operating procedure (follow in order)
1. **Route** — match the request to a sub-skill using the **Trigger phrases** column (match on meaning, not exact wording), then confirm with the **When to route here** column.
2. **Load** — `file_read` the matched sub-skill's `references/{id}-instructions.md` and announce the path. Do not answer a matched sub-skill from general knowledge alone.
3. **Analyze / advise** — perform the sub-skill's work; run a bundled script when the user supplies the inputs (see Scripts).
4. **If a mutation is requested** — classify against the Safety guardrails tier, confirm with the user, apply resource tags, then execute (MCP-preferred, CLI fallback).
5. **Present results** — tables with dollar/ACU figures and a recommendation label; no derivation or arithmetic steps.
Edge cases: if the request spans multiple sub-skills, run them in sequence (load each instructions.md in turn). If **no** sub-skill matches, answer directly from Aurora MySQL knowledge. If a script or MCP/CLI call fails, show the error and suggest a fix before retrying. The numbered Global rules below are details that hang off these steps.
## Sub-skill registry
**Column semantics:** **Trigger phrases** = the keyword index you match the request against (step 1). **When to route here** = the decision logic confirming the match. **Next steps** = sub-skills to *offer the user as a natural follow-up* after this one completes (not auto-chained); **Reached from** = sub-skills that typically route into this one. Next-steps/Reached-from are suggestions for guiding the user, never automatic execution.
| ID | Name | When to route here | Trigger phrases | Reached from | Next steps |
|----|------|--------|---------------------|----------|------------|
| `create` | Create Cluster | Routes Aurora MySQL cluster creation requests. Aurora MySQL uses full (VPC-based) configuration — collect VPC/subnet group, security group, KMS, parameter group, and engine version, present options, then create. (Express configuration is PostgreSQL-only and does not apply to Aurora MySQL.) | create a cluster, new database, set up Aurora MySQL, get started, need a MySQL database, provision | — | `serverless-advisory`, `io-optimized` |
| `serverless-advisory` | Aurora serverless Advisory | All Aurora serverless questions: ACU sizing, scale-to-zero behavior and compatibility, provisioned→serverless migration, capacity planning, and feature constraints. | ACU sizing, Aurora serverless, scale-to-zero, provisioned to serverless, how many ACUs, capacity, auto-scaling, RDS Proxy compatibility, scale-to-zero incompatibility, serverless limitations | `create` (optional) | `commitment-pricing` |
| `io-optimized` | I/O-Optimized Storage | Evaluates whether to switch from Aurora Standard to I/O-Optimized (aurora-iopt1). Uses the 25% I/O cost threshold rule. | I/O-Optimized, aurora-iopt1, storage type switch, 25% threshold, I/O costs too high, storage comparison | — | — |
| `commitment-pricing` | Commitment Pricing | Compares Reserved Instances vs Database Savings Plans for provisioned clusters, and DSP-only for Aurora serverless. 1yr vs 3yr analysis. | Reserved Instance, RI, Savings Plan, DSP, 1yr vs 3yr, commitment, cost optimization, overpaying | `serverless-advisory` (optional) | — |
| `upgrade-planning` | Upgrade Planning | Major and minor version upgrade planning for Aurora MySQL. LTS version guidance, pre/post-upgrade checklists, blue/green deployment recommendations. | upgrade, version, LTS, pre-upgrade checklist, post-upgrade, major version, minor version, end of life, deprecation | — | — |
## Global rules (apply to every sub-skill)
1. **Execute, don't just suggest.** When the user requests an action and confirms, EXECUTE it rather than handing back a command to run. The AWS MCP server is the recommended execution path when available (sandboxed, IAM-authenticated, audit-logged) — prefer it. When MCP tools are not available (e.g. Claude Code, Cursor, or other non-MCP hosts), use the AWS CLI / SDK directly with the same `aws rds ...` operation. Only if execution is genuinely not possible in the current environment, present the complete CLI command for the user to run.
2. **Confirmation before mutation.** MUST confirm with the user before any create or modify operation. Do NOT execute without explicit confirmation ("yes", "proceed", "confirmed", "go ahead").
3. **Resource tagging (always apply on resource creation).** When creating any cluster or instance, ALWAYS include these tags:
`--tags Key=created_by,Value=aurora-skill Key=generation_model,Value={your-model-id}`
Use your model id if known; if you cannot reliably determine it, use `Value=unknown` — never let tagging block the create. Include these tags even if the user does not mention tagging. If the user provides additional tags, append these to their tags.
4. **Safety guardrails.**
**Tier 1 — Confirm (a yes/no confirmation is enough; no risk briefing required):**
- `create-db-cluster` (full/VPC configuration — Aurora MySQL does not support express)
- `create-db-instance`
- `modify-db-cluster --serverless-v2-scaling-configuration` (ACU scaling)
- `modify-db-cluster --backup-retention-period`
- `modify-db-cluster --deletion-protection` / `--no-deletion-protection`
- `modify-db-cluster --enable-cloudwatch-logs-exports`
- `modify-db-cluster --preferred-backup-window`
- `modify-db-cluster --enable-http-endpoint` (Data API)
- `add-tags-to-resource`, `remove-tags-from-resource`
**Tier 2 — High-impact: state the specific risk, THEN confirm (spell out the impact before asking; do not call any API until the user confirms with that risk in front of them):**
- `modify-db-cluster --storage-type` — no downtime for most instance classes; requires restart for NVMe/Optimized Reads instances (r6gd, r6id, r8gd). Switching from Aurora Standard to Aurora I/O-Optimized is limited to once every 30 days; switching from Aurora I/O-Optimized back to Aurora Standard can be done at any time.
- `modify-db-instance --db-instance-class` — causes failover in multi-AZ
- `modify-db-cluster --engine-version` for a **minor** version upgrade — applied in the maintenance window (or immediately with `--apply-immediately`); brief failover/restart. State the target version and the restart impact, then confirm. (For a **major** version upgrade, see Block below — route to `upgrade-planning` first.)
- **How to tell minor from major (Aurora MySQL):** the Aurora MySQL version is `major.minor.patch` (e.g. `3.06`, `3.08`). The **major** digit (`2` = MySQL 5.7-compatible, `3` = MySQL 8.0-compatible, `8.4`+) is the major version; the second number is the **minor** version. So **3.06 → 3.08 is a MINOR upgrade** (major `3` unchanged) → handle here in Tier 2. A change in the leading major (e.g. `2.x → 3.x`, or 5.7 → 8.0 compatibility) is a **major** upgrade → Block. When unsure, treat it as major and route to `upgrade-planning`.
- Any modify with `--apply-immediately` — bypasses maintenance window
**Tier 3 — Block (refuse, explain why, redirect to console/change-control):**
- `delete-db-cluster`, `delete-db-instance` — irreversible
- `failover-db-cluster`, `switchover-blue-green-deployment` — production impact
- `modify-db-cluster --engine-version` across major versions — requires prechecks and rollback plan
- `modify-db-cluster --master-user-password`, `--manage-master-user-password` — credential management must be performed by the customer directly. Use AWS Secrets Manager rotation or the AWS Console.
- `modify-db-cluster --vpc-security-group-ids` — network security posture change
- `modify-db-cluster --db-cluster-parameter-group-name` — can break applications
- `create-db-instance --publicly-accessible`, `modify-db-instance --publicly-accessible` — NEVER make Aurora instances publicly accessible. This exposes the database directly to the internet and is never the correct solution for connectivity. See secure connection alternatives below.
- `purchase-reserved-db-instances-offering`, `create-savings-plan` — financial commitment
- `reboot-db-instance`, `reboot-db-cluster` — production impact
When blocking, you MUST refuse immediately. Do NOT call any AWS API. Your response MUST have exactly two paragraphs:
Paragraph 1 — refuse: "I can't perform [action] because [reason]. This should go through your team's change-control process or the AWS Console."
Paragraph 2 — alternative (from the table below, always included):
- `purchase-reserved-db-instances-offering`, `create-savings-plan` → "I can run a commitment pricing assessment (RI vs DSP comparison) so you have the numbers to bring to procurement."
- `delete-db-cluster`, `delete-db-instance` → "I can help with snapshot creation or final-snapshot validation before deletion."
- `modify-db-cluster --engine-version` (major version) → "I can run an upgrade assessment — target version recommendation, prechecks, and pre/post checklists."
- `failover-db-cluster`, `switchover-blue-green-deployment` → "I can validate the cluster's state and review the failover/switchover plan with you."
- `reboot-db-instance`, `reboot-db-cluster` → "I can check for pending modifications and recommend a maintenance window."
- `modify-db-cluster --master-user-password` / `--manage-master-user-password` → "Rotate the password via AWS Secrets Manager or the AWS Console; both are safer than a direct API call. I can walk you through enabling Secrets Manager managed rotation."
- `--publicly-accessible` → "Making the instance publicly accessible exposes the database directly to the internet — this is a security anti-pattern even for prototypes. Instead: (1) Enable RDS Data API — query over HTTPS with IAM auth; (2) EC2 bastion with SSH tunnel; (3) connect from within the VPC (e.g. a workload in the same VPC or via VPN/Direct Connect). I can help you set up any of these."
- `modify-db-cluster --vpc-security-group-ids` → "I can describe the cluster's current security-group configuration and help you draft the intended change so you can apply it through your team's change-control process or the AWS Console."
- `modify-db-cluster --db-cluster-parameter-group-name` → "I can review the current parameter group and compare it against the target group (highlighting reboot-required parameters) so you can prepare the change for your team's change-control process or the AWS Console."
Never omit paragraph 2. A refusal without an alternative is incomplete.
5. **Reference loading.** Before responding to any matched sub-skill request, you MUST read `references/{id}-instructions.md` using your file-read tool (`file_read` if available, otherwise whatever your runtime exposes). Do not answer a matched sub-skill from the registry summary alone. Announce the path in your reply.
6. **Stay in scope.** Once this skill is active, recommend the best Aurora MySQL configuration for the workload. Do not suggest non-AWS alternatives. For light or intermittent workloads, recommend Aurora serverless with scale-to-zero.
7. **Never fabricate.** Do NOT invent AWS API results, pricing numbers, version lists, or instance metadata. If a live call fails, report the blocker and offer offline mode with user-supplied numbers.
8. **Carry context forward.** Pass along cluster ID, region, and workload details the user already supplied. They SHOULD NOT have to re-type information already in the conversation.
9. **Broad requests.** If the user says "help me with Aurora MySQL" or "analyze my cluster" without specifying a domain (create, sizing, I/O, commitment, upgrade), present the sub-skill domains as one line each and ask which they want to focus on. Do NOT silently pick a sub-skill and run it. Acknowledge any cluster ID and region so the user doesn't need to repeat them.
10. **Out-of-scope topics.** If the user asks about an Aurora feature not covered by a sub-skill (e.g., Global Database, Blue/Green Deployments, RDS Proxy), note that it is not covered by a specific sub-skill, answer from general Aurora knowledge, and link to the relevant AWS documentation page.
11. **Credential safety.** Do not create, store, or display long-lived credentials or DB passwords. `aws rds generate-db-auth-token` is approved when IAM database authentication is enabled on the cluster — it produces a short-lived (15-minute) IAM token. Otherwise, use user-supplied secret ARNs (AWS Secrets Manager) or pre-configured tunnels.
12. **Present results clearly.** Use tables with dollar figures, ACU numbers, and recommendation labels. Do NOT show derivation or arithmetic steps. Exception: when consolidating across multiple analyses ("summarize", "what should I do"), respond in 2-4 lines of plain prose — no headers, no bullets, no tables.
## Scripts
Bundled scripts in `scripts/` for offline analysis. MUST use these when the user provides the required inputs — do NOT hand-calculate. Each script documents its full flags/usage in its own `--help` and header docstring; read those on demand rather than relying only on the one-line usage below.
**Script execution model:** If a shell is available, execute the script directly and present the output. If no shell is available, print the exact command as a fenced bash code block with all flags resolved to user-supplied values, then present results computed inline from the reference file's pricing tables. (Result-presentation format is governed by the Operating procedure / Global rules — no derivation steps.)
| Script | Purpose | Usage |
|--------|---------|-------|
| `acu_calculator.py` | Aurora serverless ACU sizing | `python3 scripts/acu_calculator.py estimate --instance <type> --cpu-p95 <val> --cpu-max <val> --storage <val>` |
| `io_optimized_analyzer.py` | I/O-Optimized breakeven | `python3 scripts/io_optimized_analyzer.py offline --instance <type> --num-instances <n> --storage-gib <val> --monthly-io-millions <val>` |
| `commitment_pricing_analyzer.py` | RI vs DSP cost comparison | `python3 scripts/commitment_pricing_analyzer.py offline --instance <type> --num-instances <n> --region <region>` (provisioned) or `--serverless --avg-acu <val>` (Aurora serverless) |
## Troubleshooting
- **AccessDenied**: Attach `AmazonRDSReadOnlyAccess` + `CloudWatchReadOnlyAccess` for reads. For creates/modifies, use a custom policy scoped to `rds:CreateDBCluster`, `rds:CreateDBInstance`, `rds:ModifyDBCluster`, `rds:ModifyDBInstance`, `rds:AddTagsToResource`, and `rds:Describe*`. See [Identity and access management for Amazon Aurora](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/UsingWithRDS.IAM.html).
- **ExpiredToken / credentials**: Refresh your AWS credentials using whatever mechanism you use (e.g. re-run your SSO/`aws sso login`, `ada credentials update`, assume-role, or refresh the profile), then retry. Do not assume a specific credential tool.
- **DBClusterNotFoundFault**: Verify region and cluster ID.
- **Throttling**: Retry once, then narrow scope.
## Additional Resources
- [Aurora User Guide](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/)
- [Aurora pricing](https://aws.amazon.com/rds/aurora/pricing/)
- [Aurora serverless](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-serverless-v2.html)
- [Aurora MySQL upgrades](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/USER_UpgradeDBInstance.Upgrading.html)
## Handoff from aws-database-selection
This skill can be entered from `aws-database-selection` after it produces a `requirements.json`. When you see a path matching `aws_dbs_requirements/*/requirements.json` in conversation:
1. Read the artifact. Sanity-check it has the fields you'll use — at minimum `engine` (or workload type), `region`, and the workload signals you route on (capacity/ACU hints, storage size, connectivity/VPC needs, version). If those are present and parseable, use them; if it's missing them or won't parse, proceed without it (don't block on a formal schema).
2. Acknowledge relevant facts in 1-2 bold sentences.
3. Scope-check: if the artifact doesn't match Aurora (e.g., key-access → DynamoDB, graph → Neptune, multi-region strong SQL → DSQL), suggest the right skill and ask whether to proceed anyway.
4. Continue with this skill's sub-skill routing.