service-capacity-modeling 0.3.105__tar.gz → 0.3.107__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/PKG-INFO +1 -1
  2. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/capacity_planner.py +262 -10
  3. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/interface.py +48 -1
  4. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/__init__.py +46 -0
  5. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/common.py +40 -8
  6. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/aurora.py +6 -1
  7. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/cassandra.py +80 -34
  8. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/evcache.py +87 -26
  9. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/kafka.py +39 -5
  10. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/key_value.py +44 -2
  11. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/stateless_java.py +53 -11
  12. service_capacity_modeling-0.3.107/service_capacity_modeling/models/plan_comparison.py +523 -0
  13. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling.egg-info/PKG-INFO +1 -1
  14. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling.egg-info/SOURCES.txt +2 -0
  15. service_capacity_modeling-0.3.107/tests/test_plan_comparison.py +557 -0
  16. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/LICENSE +0 -0
  17. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/README.md +0 -0
  18. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/__init__.py +0 -0
  19. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/enum_utils.py +0 -0
  20. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/__init__.py +0 -0
  21. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/__init__.py +0 -0
  22. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/pricing/aws/3yr-reserved_ec2.json +0 -0
  23. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/pricing/aws/3yr-reserved_rds.json +0 -0
  24. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/pricing/aws/3yr-reserved_zz-overrides.json +0 -0
  25. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/profiles.txt +0 -0
  26. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c5.json +0 -0
  27. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c5a.json +0 -0
  28. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c5d.json +0 -0
  29. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c5n.json +0 -0
  30. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c6a.json +0 -0
  31. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c6i.json +0 -0
  32. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c6id.json +0 -0
  33. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c7a.json +0 -0
  34. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c7i.json +0 -0
  35. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_c8i.json +0 -0
  36. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_db_r6g.json +0 -0
  37. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_db_r6i.json +0 -0
  38. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_db_r7g.json +0 -0
  39. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_db_r7i.json +0 -0
  40. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_i3en.json +0 -0
  41. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_i4i.json +0 -0
  42. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_i7i.json +0 -0
  43. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m4.json +0 -0
  44. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m5.json +0 -0
  45. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m5n.json +0 -0
  46. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6a.json +0 -0
  47. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6i.json +0 -0
  48. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6id.json +0 -0
  49. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6idn.json +0 -0
  50. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m6in.json +0 -0
  51. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m7a.json +0 -0
  52. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m7i.json +0 -0
  53. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_m8i.json +0 -0
  54. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r4.json +0 -0
  55. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r5.json +0 -0
  56. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r5n.json +0 -0
  57. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6a.json +0 -0
  58. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6i.json +0 -0
  59. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6id.json +0 -0
  60. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6idn.json +0 -0
  61. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r6in.json +0 -0
  62. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r7a.json +0 -0
  63. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r7i.json +0 -0
  64. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/auto_r8i.json +0 -0
  65. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/manual_drives.json +0 -0
  66. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/manual_instances.json +0 -0
  67. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/hardware/profiles/shapes/aws/manual_services.json +0 -0
  68. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/headroom_strategy.py +0 -0
  69. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/__init__.py +0 -0
  70. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/__init__.py +0 -0
  71. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/control.py +0 -0
  72. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/counter.py +0 -0
  73. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/crdb.py +0 -0
  74. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/ddb.py +0 -0
  75. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/elasticsearch.py +0 -0
  76. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/entity.py +0 -0
  77. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/graphkv.py +0 -0
  78. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/iso_date_math.py +0 -0
  79. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/postgres.py +0 -0
  80. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/rds.py +0 -0
  81. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/time_series.py +0 -0
  82. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/time_series_config.py +0 -0
  83. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/wal.py +0 -0
  84. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/org/netflix/zookeeper.py +0 -0
  85. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/models/utils.py +0 -0
  86. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/stats.py +0 -0
  87. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/tools/__init__.py +0 -0
  88. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/tools/auto_shape.py +0 -0
  89. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/tools/capture_baseline_costs.py +0 -0
  90. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/tools/data/__init__.py +0 -0
  91. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/tools/fetch_pricing.py +0 -0
  92. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/tools/generate_missing.py +0 -0
  93. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling/tools/instance_families.py +0 -0
  94. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling.egg-info/dependency_links.txt +0 -0
  95. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling.egg-info/entry_points.txt +0 -0
  96. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling.egg-info/requires.txt +0 -0
  97. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/service_capacity_modeling.egg-info/top_level.txt +0 -0
  98. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/setup.cfg +0 -0
  99. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/setup.py +0 -0
  100. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_arguments.py +0 -0
  101. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_buffers.py +0 -0
  102. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_common.py +0 -0
  103. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_desire_merge.py +0 -0
  104. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_enum_utils.py +0 -0
  105. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_generate_scenarios.py +0 -0
  106. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_hardware.py +0 -0
  107. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_hardware_shapes.py +0 -0
  108. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_headroom_strategy.py +0 -0
  109. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_io2.py +0 -0
  110. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_model_dump.py +0 -0
  111. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_reproducible.py +0 -0
  112. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_simulation.py +0 -0
  113. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_utils.py +0 -0
  114. {service_capacity_modeling-0.3.105 → service_capacity_modeling-0.3.107}/tests/test_working_set.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: service-capacity-modeling
3
- Version: 0.3.105
3
+ Version: 0.3.107
4
4
  Summary: Contains utilities for modeling capacity for pluggable workloads
5
5
  Author: Joseph Lynch
6
6
  Author-email: josephl@netflix.com
@@ -1,4 +1,5 @@
1
1
  # -*- coding: utf-8 -*-
2
+ # pylint: disable=too-many-lines
2
3
  import functools
3
4
  import logging
4
5
  import math
@@ -23,6 +24,9 @@ from service_capacity_modeling.interface import CapacityPlan
23
24
  from service_capacity_modeling.interface import CapacityRegretParameters
24
25
  from service_capacity_modeling.interface import CapacityRequirement
25
26
  from service_capacity_modeling.interface import certain_float
27
+ from service_capacity_modeling.interface import ClusterCapacity
28
+ from service_capacity_modeling.interface import Clusters
29
+ from service_capacity_modeling.interface import CurrentClusterCapacity
26
30
  from service_capacity_modeling.interface import DataShape
27
31
  from service_capacity_modeling.interface import Drive
28
32
  from service_capacity_modeling.interface import Hardware
@@ -33,10 +37,15 @@ from service_capacity_modeling.interface import Lifecycle
33
37
  from service_capacity_modeling.interface import PlanExplanation
34
38
  from service_capacity_modeling.interface import Platform
35
39
  from service_capacity_modeling.interface import QueryPattern
40
+ from service_capacity_modeling.interface import RegionClusterCapacity
36
41
  from service_capacity_modeling.interface import RegionContext
37
42
  from service_capacity_modeling.interface import Requirements
43
+ from service_capacity_modeling.interface import ServiceCapacity
38
44
  from service_capacity_modeling.interface import UncertainCapacityPlan
45
+ from service_capacity_modeling.interface import ZoneClusterCapacity
39
46
  from service_capacity_modeling.models import CapacityModel
47
+ from service_capacity_modeling.models import CostAwareModel
48
+ from service_capacity_modeling.models.common import get_disk_size_gib
40
49
  from service_capacity_modeling.models.common import merge_plan
41
50
  from service_capacity_modeling.models.org import netflix
42
51
  from service_capacity_modeling.models.utils import reduce_by_family
@@ -211,6 +220,78 @@ def _set_instance_objects(
211
220
  )
212
221
 
213
222
 
223
+ def _extract_cluster_plan(
224
+ clusters: Sequence[CurrentClusterCapacity],
225
+ hardware: Hardware,
226
+ is_zonal: bool,
227
+ ) -> Tuple[List[ClusterCapacity], List[CapacityRequirement]]:
228
+ """Extract CapacityPlan components from current deployment.
229
+
230
+ Takes what's currently deployed and builds the ClusterCapacity and
231
+ CapacityRequirement objects needed for a CapacityPlan.
232
+
233
+ Drives are priced using hardware.price_drive() to get catalog pricing.
234
+ Cluster annual_cost is computed automatically from instance + drives.
235
+
236
+ Args:
237
+ clusters: Current cluster capacities (must have cluster_type set)
238
+ hardware: Hardware catalog for the region (used to price drives)
239
+ is_zonal: True for ZoneClusterCapacity, False for RegionClusterCapacity
240
+
241
+ Returns:
242
+ Tuple of (capacities, requirements) for building CapacityPlan
243
+
244
+ Raises:
245
+ ValueError: If any cluster is missing cluster_type
246
+ """
247
+ capacities: List[ClusterCapacity] = []
248
+ requirements: List[CapacityRequirement] = []
249
+
250
+ for current in clusters:
251
+ if current.cluster_type is None:
252
+ raise ValueError(
253
+ f"cluster_type is required for baseline extraction. "
254
+ f"Cluster '{current.cluster_instance_name}' is missing cluster_type."
255
+ )
256
+ cluster_type = current.cluster_type
257
+ instance = current.cluster_instance
258
+ if instance is None:
259
+ raise ValueError(
260
+ f"cluster_instance not resolved for '{current.cluster_instance_name}'"
261
+ )
262
+ count = int(current.cluster_instance_count.mid)
263
+
264
+ # Price the drive from hardware catalog (gets annual_cost_per_gib etc.)
265
+ attached_drives = []
266
+ if current.cluster_drive is not None:
267
+ attached_drives.append(hardware.price_drive(current.cluster_drive))
268
+
269
+ disk_gib = get_disk_size_gib(current.cluster_drive, instance)
270
+
271
+ capacity_cls = ZoneClusterCapacity if is_zonal else RegionClusterCapacity
272
+ capacities.append(
273
+ capacity_cls(
274
+ cluster_type=cluster_type,
275
+ count=count,
276
+ instance=instance,
277
+ attached_drives=attached_drives,
278
+ )
279
+ )
280
+
281
+ requirements.append(
282
+ CapacityRequirement(
283
+ requirement_type=cluster_type,
284
+ reference_shape=instance,
285
+ cpu_cores=certain_float(instance.cpu * count),
286
+ mem_gib=certain_float(instance.ram_gib * count),
287
+ network_mbps=certain_float(instance.net_mbps * count),
288
+ disk_gib=certain_float(disk_gib * count),
289
+ )
290
+ )
291
+
292
+ return capacities, requirements
293
+
294
+
214
295
  def _allow_instance(
215
296
  instance: Instance,
216
297
  allowed_names: Sequence[str],
@@ -340,6 +421,7 @@ class CapacityPlanner:
340
421
  ) -> None:
341
422
  self._shapes: HardwareShapes = shapes
342
423
  self._models: Dict[str, CapacityModel] = {}
424
+ self._cluster_types: Dict[str, str] = {} # cluster_type -> model_name
343
425
 
344
426
  self._default_num_simulations = default_num_simulations
345
427
  self._default_num_results = default_num_results
@@ -351,6 +433,25 @@ class CapacityPlanner:
351
433
  self.register_model(name, model)
352
434
 
353
435
  def register_model(self, name: str, capacity_model: CapacityModel) -> None:
436
+ if isinstance(capacity_model, CostAwareModel):
437
+ # Validate required attributes
438
+ sn = getattr(capacity_model, "service_name", None)
439
+ ct = getattr(capacity_model, "cluster_type", None)
440
+ if not sn or not ct:
441
+ raise ValueError(
442
+ f"CostAwareModel '{name}' must define service_name and "
443
+ f"cluster_type (got service_name={sn!r}, cluster_type={ct!r})"
444
+ )
445
+
446
+ # Duplicate cluster_type would cause double-counting
447
+ if ct in self._cluster_types:
448
+ raise ValueError(
449
+ f"Duplicate cluster_type '{ct}': '{name}' "
450
+ f"conflicts with '{self._cluster_types[ct]}'. Must be unique "
451
+ f"to avoid double-counting costs."
452
+ )
453
+ self._cluster_types[ct] = name
454
+
354
455
  self._models[name] = capacity_model
355
456
 
356
457
  @property
@@ -364,6 +465,23 @@ class CapacityPlanner:
364
465
  def instance(self, name: str, region: Optional[str] = None) -> Instance:
365
466
  return self.hardware_shapes.instance(name, region=region)
366
467
 
468
+ def _prepare_context(
469
+ self,
470
+ region: str,
471
+ num_regions: int,
472
+ ) -> Tuple[Hardware, RegionContext]:
473
+ """Prepare hardware and region context for capacity planning.
474
+
475
+ Loads hardware catalog for region and creates RegionContext.
476
+ """
477
+ hardware = self._shapes.region(region)
478
+ context = RegionContext(
479
+ zones_in_region=hardware.zones_in_region,
480
+ services={n: s.model_copy(deep=True) for n, s in hardware.services.items()},
481
+ num_regions=num_regions,
482
+ )
483
+ return hardware, context
484
+
367
485
  def _plan_percentiles( # pylint: disable=too-many-positional-arguments
368
486
  self,
369
487
  model_name: str,
@@ -587,6 +705,146 @@ class CapacityPlanner:
587
705
  :num_results
588
706
  ]
589
707
 
708
+ def _get_model_costs(
709
+ self,
710
+ *,
711
+ model_name: str,
712
+ context: RegionContext,
713
+ desires: CapacityDesires,
714
+ zonal_clusters: Sequence[ClusterCapacity],
715
+ regional_clusters: Sequence[ClusterCapacity],
716
+ extra_model_arguments: Dict[str, Any],
717
+ ) -> Tuple[Dict[str, float], List[ServiceCapacity]]:
718
+ """Get total costs for a model and any models it composes with."""
719
+ costs: Dict[str, float] = {}
720
+ services: List[ServiceCapacity] = []
721
+
722
+ for sub_model_name, sub_desires in self._sub_models(
723
+ model_name, desires, extra_model_arguments
724
+ ):
725
+ sub_model = self._models[sub_model_name]
726
+ if not isinstance(sub_model, CostAwareModel):
727
+ raise TypeError(
728
+ f"Sub-model '{sub_model_name}' does not implement CostAwareModel. "
729
+ f"All models in the composition tree must implement cost methods."
730
+ )
731
+
732
+ model_costs = sub_model.cluster_costs(
733
+ service_type=sub_model.service_name,
734
+ zonal_clusters=zonal_clusters,
735
+ regional_clusters=regional_clusters,
736
+ )
737
+ costs.update(model_costs)
738
+
739
+ model_services = sub_model.service_costs(
740
+ service_type=sub_model.service_name,
741
+ context=context,
742
+ desires=sub_desires,
743
+ extra_model_arguments=extra_model_arguments,
744
+ )
745
+ for svc in model_services:
746
+ costs[svc.service_type] = svc.annual_cost
747
+ services.extend(model_services)
748
+
749
+ return costs, services
750
+
751
+ def extract_baseline_plan( # pylint: disable=too-many-positional-arguments
752
+ self,
753
+ model_name: str,
754
+ region: str,
755
+ desires: CapacityDesires,
756
+ num_regions: int = 3,
757
+ extra_model_arguments: Optional[Dict[str, Any]] = None,
758
+ ) -> CapacityPlan:
759
+ """Extract baseline plan from current clusters using model cost methods.
760
+
761
+ This converts the current deployment (from desires.current_clusters) into
762
+ a CapacityPlan that can be compared against recommendations. Uses model-
763
+ specific cost methods for accurate pricing.
764
+
765
+ Note: Only works for models with CostAwareModel mixin (EVCache, Kafka,
766
+ Cassandra, Key-Value). Other models will raise AttributeError.
767
+
768
+ Supports composite models (like Key-Value) that have both zonal and
769
+ regional clusters - each model's cluster_costs filters by cluster_type.
770
+
771
+ Args:
772
+ model_name: Registered model name (e.g., "org.netflix.cassandra")
773
+ region: AWS region for pricing
774
+ desires: CapacityDesires with current_clusters populated
775
+ num_regions: For cross-region cost calculation (default: 3)
776
+ extra_model_arguments: Model-specific arguments (e.g., copies_per_region)
777
+
778
+ Returns:
779
+ CapacityPlan with costs from model.cluster_costs() and model.service_costs()
780
+
781
+ Raises:
782
+ ValueError: If model_name not found or current_clusters invalid
783
+ AttributeError: If model doesn't have CostAwareModel mixin
784
+ """
785
+ extra_model_arguments = extra_model_arguments or {}
786
+ if model_name not in self._models:
787
+ raise ValueError(
788
+ f"model_name={model_name} does not exist. "
789
+ f"Try {sorted(list(self._models.keys()))}"
790
+ )
791
+
792
+ model = self._models[model_name]
793
+ if not isinstance(model, CostAwareModel):
794
+ raise TypeError(f"Model '{model_name}' must implement CostAwareModel mixin")
795
+
796
+ if desires.current_clusters is None:
797
+ raise ValueError(
798
+ "Cannot extract baseline: desires.current_clusters is None. "
799
+ "This function requires an existing deployment to compare against."
800
+ )
801
+ if not desires.current_clusters.zonal and not desires.current_clusters.regional:
802
+ raise ValueError(
803
+ "Cannot extract baseline: desires.current_clusters has no zonal "
804
+ "or regional clusters defined."
805
+ )
806
+
807
+ hardware, context = self._prepare_context(region, num_regions)
808
+ _set_instance_objects(
809
+ desires, hardware
810
+ ) # Resolve instance refs in current_clusters
811
+
812
+ zonal_capacities: List[ClusterCapacity] = []
813
+ zonal_requirements: List[CapacityRequirement] = []
814
+ regional_capacities: List[ClusterCapacity] = []
815
+ regional_requirements: List[CapacityRequirement] = []
816
+
817
+ if desires.current_clusters.zonal:
818
+ zonal_capacities, zonal_requirements = _extract_cluster_plan(
819
+ desires.current_clusters.zonal, hardware, is_zonal=True
820
+ )
821
+ if desires.current_clusters.regional:
822
+ regional_capacities, regional_requirements = _extract_cluster_plan(
823
+ desires.current_clusters.regional, hardware, is_zonal=False
824
+ )
825
+
826
+ costs, services = self._get_model_costs(
827
+ model_name=model_name,
828
+ context=context,
829
+ desires=desires,
830
+ zonal_clusters=zonal_capacities,
831
+ regional_clusters=regional_capacities,
832
+ extra_model_arguments=extra_model_arguments,
833
+ )
834
+
835
+ return CapacityPlan(
836
+ requirements=Requirements(
837
+ zonal=zonal_requirements,
838
+ regional=regional_requirements,
839
+ ),
840
+ candidate_clusters=Clusters(
841
+ annual_costs=costs,
842
+ zonal=zonal_capacities,
843
+ regional=regional_capacities,
844
+ services=services,
845
+ ),
846
+ )
847
+
590
848
  # Calculates the minimum cpu, memory, and network requirements based on desires.
591
849
  def _per_instance_requirements(self, desires: CapacityDesires) -> Tuple[int, float]:
592
850
  # Applications often set fixed reservations of heap or OS memory
@@ -636,13 +894,10 @@ class CapacityPlanner:
636
894
  instance_families = instance_families or []
637
895
  drives = drives or []
638
896
 
639
- hardware = self._shapes.region(region)
640
-
641
- context = RegionContext(
642
- zones_in_region=hardware.zones_in_region,
643
- services={n: s.model_copy(deep=True) for n, s in hardware.services.items()},
644
- num_regions=num_regions,
645
- )
897
+ hardware, context = self._prepare_context(region, num_regions)
898
+ _set_instance_objects(
899
+ desires, hardware
900
+ ) # Resolve instance refs if current_clusters exists
646
901
 
647
902
  allowed_platforms: Set[Platform] = set(model.allowed_platforms())
648
903
  allowed_drives: Set[str] = set(drives or [])
@@ -654,9 +909,6 @@ class CapacityPlanner:
654
909
  if len(allowed_drives) == 0:
655
910
  allowed_drives.update(hardware.drives.keys())
656
911
 
657
- # Set current instance object if exists
658
- _set_instance_objects(desires, hardware)
659
-
660
912
  # We should not even bother with shapes that don't meet the minimums
661
913
  (
662
914
  per_instance_cores,
@@ -565,6 +565,30 @@ class Hardware(ExcludeUnsetModel):
565
565
  """Managed services available (e.g. service name -> Service with
566
566
  params, cost, etc.)"""
567
567
 
568
+ def price_drive(self, drive: Drive) -> Drive:
569
+ """Hydrate a drive with pricing information from the hardware catalog.
570
+
571
+ User-provided drives (e.g., from CurrentClusterCapacity.cluster_drive)
572
+ contain size/IOPS but lack pricing. This method looks up pricing by
573
+ drive name and returns a properly priced Drive instance.
574
+
575
+ Args:
576
+ drive: Drive with name, size_gib, read_io_per_s, write_io_per_s
577
+
578
+ Returns:
579
+ Drive with catalog pricing and input size/IO values
580
+
581
+ Raises:
582
+ ValueError: If drive.name not in hardware catalog
583
+ """
584
+ if drive.name not in self.drives:
585
+ raise ValueError(f"Cannot price drive '{drive.name}'")
586
+ priced = self.drives[drive.name].model_copy()
587
+ priced.size_gib = drive.size_gib
588
+ priced.read_io_per_s = drive.read_io_per_s
589
+ priced.write_io_per_s = drive.write_io_per_s
590
+ return priced
591
+
568
592
 
569
593
  class GlobalHardware(ExcludeUnsetModel):
570
594
  """Represents all possible hardware shapes in all regions
@@ -821,6 +845,10 @@ class CurrentClusterCapacity(ExcludeUnsetModel):
821
845
  cluster_instance: Optional[Instance] = None
822
846
  cluster_drive: Optional[Drive] = None
823
847
  cluster_instance_count: Interval
848
+ # Optional: if not set, extract_baseline_plan
849
+ # Required metadata for identifying which model
850
+ # this capacity belongs to
851
+ cluster_type: Optional[str] = None
824
852
  # The distribution cpu utilization in the cluster.
825
853
  cpu_utilization: Interval = certain_float(0.0)
826
854
  # The per node distribution of memory used in gib.
@@ -1122,11 +1150,30 @@ class ClusterCapacity(ExcludeUnsetModel):
1122
1150
  count: int
1123
1151
  instance: Instance
1124
1152
  attached_drives: Sequence[Drive] = ()
1125
- annual_cost: float
1126
1153
  # When provisioning services we might need to signal they
1127
1154
  # should have certain configuration, for example flags that
1128
1155
  # affect durability shut off
1129
1156
  cluster_params: Dict[str, Any] = {}
1157
+ # Override for models with non-standard cost calculation (e.g., Aurora
1158
+ # has shared storage so drive cost isn't multiplied by count)
1159
+ annual_cost_override: Optional[float] = None
1160
+
1161
+ @computed_field(return_type=float) # type: ignore
1162
+ @property
1163
+ def annual_cost(self) -> float:
1164
+ """Compute annual cost from instance and attached drives.
1165
+
1166
+ Standard formula: count * instance.annual_cost + sum(drive.annual_cost * count)
1167
+
1168
+ Models with different cost structures (e.g., Aurora with shared storage)
1169
+ can set annual_cost_override to bypass this calculation.
1170
+ """
1171
+ if self.annual_cost_override is not None:
1172
+ return self.annual_cost_override
1173
+ cost = self.count * self.instance.annual_cost
1174
+ for drive in self.attached_drives:
1175
+ cost += drive.annual_cost * self.count
1176
+ return cost
1130
1177
 
1131
1178
 
1132
1179
  class ServiceCapacity(ExcludeUnsetModel):
@@ -1,7 +1,9 @@
1
1
  from typing import Any
2
2
  from typing import Callable
3
3
  from typing import Dict
4
+ from typing import List
4
5
  from typing import Optional
6
+ from typing import Sequence
5
7
  from typing import Tuple
6
8
 
7
9
  from service_capacity_modeling.interface import AccessConsistency
@@ -18,7 +20,9 @@ from service_capacity_modeling.interface import GlobalConsistency
18
20
  from service_capacity_modeling.interface import Instance
19
21
  from service_capacity_modeling.interface import Platform
20
22
  from service_capacity_modeling.interface import QueryPattern
23
+ from service_capacity_modeling.interface import ClusterCapacity
21
24
  from service_capacity_modeling.interface import RegionContext
25
+ from service_capacity_modeling.interface import ServiceCapacity
22
26
 
23
27
  __all__ = [
24
28
  "AccessConsistency",
@@ -37,6 +41,7 @@ __all__ = [
37
41
  "QueryPattern",
38
42
  "RegionContext",
39
43
  "CapacityModel",
44
+ "CostAwareModel",
40
45
  ]
41
46
 
42
47
  __common_regrets__ = frozenset(("spend", "disk", "mem"))
@@ -87,6 +92,47 @@ def _disk_regret( # noqa: C901
87
92
  return regret
88
93
 
89
94
 
95
+ class CostAwareModel:
96
+ """Mixin for models that implement cost calculation methods.
97
+
98
+ Models using this mixin MUST define:
99
+ service_name: str # prefix for cost keys (e.g., "cassandra")
100
+ cluster_type: str # filters which clusters this model costs
101
+
102
+ Example:
103
+ class MyModel(CapacityModel, CostAwareModel):
104
+ service_name = "myservice"
105
+ cluster_type = "myservice"
106
+ """
107
+
108
+ # Subclasses MUST override these (validated in register_model)
109
+ service_name: str
110
+ cluster_type: str
111
+
112
+ @staticmethod
113
+ def service_costs(
114
+ service_type: str,
115
+ context: RegionContext,
116
+ desires: CapacityDesires,
117
+ extra_model_arguments: Dict[str, Any],
118
+ ) -> List[ServiceCapacity]:
119
+ """Calculate additional service costs (network, backup, etc)."""
120
+ raise NotImplementedError(
121
+ f"service_costs() must be implemented by {service_type} model"
122
+ )
123
+
124
+ @staticmethod
125
+ def cluster_costs(
126
+ service_type: str,
127
+ zonal_clusters: Sequence["ClusterCapacity"] = (),
128
+ regional_clusters: Sequence["ClusterCapacity"] = (),
129
+ ) -> Dict[str, float]:
130
+ """Calculate cluster infrastructure costs (instances, drives)."""
131
+ raise NotImplementedError(
132
+ f"cluster_costs() must be implemented by {service_type} model"
133
+ )
134
+
135
+
90
136
  class CapacityModel:
91
137
  """Stateless interface for defining a capacity model
92
138
 
@@ -8,6 +8,7 @@ from typing import Callable
8
8
  from typing import Dict
9
9
  from typing import List
10
10
  from typing import Optional
11
+ from typing import Sequence
11
12
  from typing import Set
12
13
  from typing import Tuple
13
14
 
@@ -25,6 +26,7 @@ from service_capacity_modeling.interface import CapacityPlan
25
26
  from service_capacity_modeling.interface import CapacityRequirement
26
27
  from service_capacity_modeling.interface import certain_float
27
28
  from service_capacity_modeling.interface import certain_int
29
+ from service_capacity_modeling.interface import ClusterCapacity
28
30
  from service_capacity_modeling.interface import Clusters
29
31
  from service_capacity_modeling.interface import CurrentClusterCapacity
30
32
  from service_capacity_modeling.interface import CurrentClusters
@@ -48,6 +50,31 @@ logger = logging.getLogger(__name__)
48
50
  SECONDS_IN_YEAR = 31556926
49
51
 
50
52
 
53
+ def cluster_infra_cost(
54
+ service_type: str,
55
+ zonal_clusters: Sequence[ClusterCapacity],
56
+ regional_clusters: Sequence[ClusterCapacity],
57
+ cluster_type: Optional[str] = None,
58
+ ) -> Dict[str, float]:
59
+ """Sum cluster annual_costs, optionally filtering by cluster_type."""
60
+ if cluster_type is not None:
61
+ zonal_clusters = [c for c in zonal_clusters if c.cluster_type == cluster_type]
62
+ regional_clusters = [
63
+ c for c in regional_clusters if c.cluster_type == cluster_type
64
+ ]
65
+
66
+ costs: Dict[str, float] = {}
67
+ if zonal_clusters:
68
+ costs[f"{service_type}.zonal-clusters"] = sum(
69
+ c.annual_cost for c in zonal_clusters
70
+ )
71
+ if regional_clusters:
72
+ costs[f"{service_type}.regional-clusters"] = sum(
73
+ c.annual_cost for c in regional_clusters
74
+ )
75
+ return costs
76
+
77
+
51
78
  # In square root staffing we have to take into account the QOS parameter
52
79
  # Which is related to the probability that a user queues. On low tier clusters
53
80
  # (aka critical clusters) we want a lower probability of queueing
@@ -92,6 +119,17 @@ def _sqrt_staffed_cores(rps: float, latency_s: float, qos: float) -> int:
92
119
  return math.ceil((rps * latency_s) + qos * math.sqrt(rps * latency_s))
93
120
 
94
121
 
122
+ def get_disk_size_gib(
123
+ cluster_drive: Optional[Drive],
124
+ instance: Instance,
125
+ ) -> float:
126
+ if cluster_drive is not None:
127
+ return cluster_drive.size_gib or 0.0
128
+ if instance.drive is not None:
129
+ return instance.drive.size_gib or 0.0
130
+ return 0.0
131
+
132
+
95
133
  def get_effective_disk_per_node_gib(
96
134
  instance: Instance,
97
135
  drive: Drive,
@@ -910,14 +948,8 @@ class RequirementFromCurrentCapacity(BaseModel):
910
948
  self.current_capacity.disk_utilization_gib.mid
911
949
  * self.current_capacity.cluster_instance_count.mid
912
950
  )
913
- current_node_disk_gib = float(
914
- self.current_instance.drive.max_size_gib
915
- if self.current_instance.drive is not None
916
- else (
917
- self.current_capacity.cluster_drive.size_gib
918
- if self.current_capacity.cluster_drive is not None
919
- else 0
920
- )
951
+ current_node_disk_gib = get_disk_size_gib(
952
+ self.current_capacity.cluster_drive, self.current_instance
921
953
  )
922
954
 
923
955
  zonal_disk_allocated = float(
@@ -178,6 +178,10 @@ def _compute_aurora_region( # pylint: disable=too-many-positional-arguments
178
178
  drive.annual_cost_per_read_io[0][1],
179
179
  drive.annual_cost_per_write_io[0][1],
180
180
  )
181
+
182
+ # TODO (homatthew): Should instance.annual_cost be multiplied by instance_count?
183
+ # Aurora has shared storage, so storage cost is correct (not multiplied).
184
+ # But compute cost should arguably be instance_count * instance.annual_cost.
181
185
  total_annual_cost = instance.annual_cost + attached_drive.annual_cost + io_cost
182
186
 
183
187
  logger.debug(
@@ -202,7 +206,8 @@ def _compute_aurora_region( # pylint: disable=too-many-positional-arguments
202
206
  count=instance_count,
203
207
  instance=instance,
204
208
  attached_drives=attached_drives,
205
- annual_cost=total_annual_cost,
209
+ # Aurora's cost model differs from standard (shared storage), so override
210
+ annual_cost_override=total_annual_cost,
206
211
  cluster_params={"instance_cost": instance.annual_cost},
207
212
  )
208
213