service-capacity-modeling 0.3.105__py3-none-any.whl → 0.3.106__py3-none-any.whl
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.
- service_capacity_modeling/capacity_planner.py +262 -10
- service_capacity_modeling/interface.py +48 -1
- service_capacity_modeling/models/__init__.py +46 -0
- service_capacity_modeling/models/common.py +40 -8
- service_capacity_modeling/models/org/netflix/aurora.py +6 -1
- service_capacity_modeling/models/org/netflix/cassandra.py +80 -34
- service_capacity_modeling/models/org/netflix/evcache.py +87 -26
- service_capacity_modeling/models/org/netflix/kafka.py +39 -5
- service_capacity_modeling/models/org/netflix/key_value.py +44 -2
- service_capacity_modeling/models/org/netflix/stateless_java.py +53 -11
- {service_capacity_modeling-0.3.105.dist-info → service_capacity_modeling-0.3.106.dist-info}/METADATA +1 -1
- {service_capacity_modeling-0.3.105.dist-info → service_capacity_modeling-0.3.106.dist-info}/RECORD +16 -16
- {service_capacity_modeling-0.3.105.dist-info → service_capacity_modeling-0.3.106.dist-info}/WHEEL +0 -0
- {service_capacity_modeling-0.3.105.dist-info → service_capacity_modeling-0.3.106.dist-info}/entry_points.txt +0 -0
- {service_capacity_modeling-0.3.105.dist-info → service_capacity_modeling-0.3.106.dist-info}/licenses/LICENSE +0 -0
- {service_capacity_modeling-0.3.105.dist-info → service_capacity_modeling-0.3.106.dist-info}/top_level.txt +0 -0
|
@@ -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.
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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 =
|
|
914
|
-
self.
|
|
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
|
-
|
|
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
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
# pylint: disable=too-many-lines
|
|
1
2
|
import logging
|
|
2
3
|
import math
|
|
3
4
|
from typing import Any
|
|
4
5
|
from typing import Callable
|
|
5
6
|
from typing import Dict
|
|
7
|
+
from typing import List
|
|
6
8
|
from typing import Optional
|
|
9
|
+
from typing import Sequence
|
|
7
10
|
from typing import Set
|
|
8
11
|
|
|
9
12
|
from pydantic import BaseModel
|
|
@@ -19,6 +22,7 @@ from service_capacity_modeling.interface import CapacityPlan
|
|
|
19
22
|
from service_capacity_modeling.interface import CapacityRequirement
|
|
20
23
|
from service_capacity_modeling.interface import certain_float
|
|
21
24
|
from service_capacity_modeling.interface import certain_int
|
|
25
|
+
from service_capacity_modeling.interface import ClusterCapacity
|
|
22
26
|
from service_capacity_modeling.interface import Clusters
|
|
23
27
|
from service_capacity_modeling.interface import Consistency
|
|
24
28
|
from service_capacity_modeling.interface import CurrentClusterCapacity
|
|
@@ -33,7 +37,9 @@ from service_capacity_modeling.interface import RegionContext
|
|
|
33
37
|
from service_capacity_modeling.interface import Requirements
|
|
34
38
|
from service_capacity_modeling.interface import ServiceCapacity
|
|
35
39
|
from service_capacity_modeling.models import CapacityModel
|
|
40
|
+
from service_capacity_modeling.models import CostAwareModel
|
|
36
41
|
from service_capacity_modeling.models.common import buffer_for_components
|
|
42
|
+
from service_capacity_modeling.models.common import cluster_infra_cost
|
|
37
43
|
from service_capacity_modeling.models.common import compute_stateful_zone
|
|
38
44
|
from service_capacity_modeling.models.common import DerivedBuffers
|
|
39
45
|
from service_capacity_modeling.models.common import get_effective_disk_per_node_gib
|
|
@@ -195,11 +201,7 @@ def _estimate_cassandra_requirement(
|
|
|
195
201
|
zones_per_region: int = 3,
|
|
196
202
|
copies_per_region: int = 3,
|
|
197
203
|
) -> CapacityRequirement:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
The input desires should be the **regional** desire, and this function will
|
|
201
|
-
return the zonal capacity requirement
|
|
202
|
-
"""
|
|
204
|
+
# Input: regional desires → Output: zonal requirement
|
|
203
205
|
disk_buffer = buffer_for_components(
|
|
204
206
|
buffers=desires.buffers, components=[BufferComponent.disk]
|
|
205
207
|
)
|
|
@@ -533,40 +535,27 @@ def _estimate_cassandra_cluster_zonal( # pylint: disable=too-many-positional-ar
|
|
|
533
535
|
if cluster.count > (max_regional_size // zones_per_region):
|
|
534
536
|
return None
|
|
535
537
|
|
|
536
|
-
#
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
cap_services = [
|
|
544
|
-
ServiceCapacity(
|
|
545
|
-
service_type=f"cassandra.backup.{blob.name}",
|
|
546
|
-
annual_cost=blob.annual_cost_gib(requirement.disk_gib.mid),
|
|
547
|
-
service_params={
|
|
548
|
-
"nines_required": (
|
|
549
|
-
1 - 1.0 / desires.data_shape.durability_slo_order.mid
|
|
550
|
-
)
|
|
551
|
-
},
|
|
552
|
-
)
|
|
553
|
-
]
|
|
538
|
+
# Calculate service costs (network + backup)
|
|
539
|
+
cap_services = NflxCassandraCapacityModel.service_costs(
|
|
540
|
+
service_type=NflxCassandraCapacityModel.service_name,
|
|
541
|
+
context=context,
|
|
542
|
+
desires=desires,
|
|
543
|
+
extra_model_arguments={"copies_per_region": copies_per_region},
|
|
544
|
+
)
|
|
554
545
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
cap_services.extend(network_costs)
|
|
546
|
+
cluster.cluster_type = NflxCassandraCapacityModel.cluster_type
|
|
547
|
+
zonal_clusters = [cluster] * zones_per_region
|
|
558
548
|
|
|
559
549
|
# Account for the clusters, backup, and network costs
|
|
560
|
-
cassandra_costs =
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
550
|
+
cassandra_costs = NflxCassandraCapacityModel.cluster_costs(
|
|
551
|
+
service_type=NflxCassandraCapacityModel.service_name,
|
|
552
|
+
zonal_clusters=zonal_clusters,
|
|
553
|
+
)
|
|
554
|
+
cassandra_costs.update({s.service_type: s.annual_cost for s in cap_services})
|
|
565
555
|
|
|
566
|
-
cluster.cluster_type = "cassandra"
|
|
567
556
|
clusters = Clusters(
|
|
568
557
|
annual_costs=cassandra_costs,
|
|
569
|
-
zonal=
|
|
558
|
+
zonal=zonal_clusters,
|
|
570
559
|
regional=[],
|
|
571
560
|
services=cap_services,
|
|
572
561
|
)
|
|
@@ -711,7 +700,10 @@ class NflxCassandraArguments(BaseModel):
|
|
|
711
700
|
return cls.model_validate(args)
|
|
712
701
|
|
|
713
702
|
|
|
714
|
-
class NflxCassandraCapacityModel(CapacityModel):
|
|
703
|
+
class NflxCassandraCapacityModel(CapacityModel, CostAwareModel):
|
|
704
|
+
service_name = "cassandra"
|
|
705
|
+
cluster_type = "cassandra"
|
|
706
|
+
|
|
715
707
|
def __init__(self) -> None:
|
|
716
708
|
pass
|
|
717
709
|
|
|
@@ -744,6 +736,60 @@ class NflxCassandraCapacityModel(CapacityModel):
|
|
|
744
736
|
|
|
745
737
|
return required_cluster_size
|
|
746
738
|
|
|
739
|
+
@staticmethod
|
|
740
|
+
def service_costs(
|
|
741
|
+
service_type: str,
|
|
742
|
+
context: RegionContext,
|
|
743
|
+
desires: CapacityDesires,
|
|
744
|
+
extra_model_arguments: Dict[str, Any],
|
|
745
|
+
) -> List[ServiceCapacity]:
|
|
746
|
+
# C* service costs: network + backup
|
|
747
|
+
copies_per_region: int = _target_rf(
|
|
748
|
+
desires, extra_model_arguments.get("copies_per_region")
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
services: List[ServiceCapacity] = []
|
|
752
|
+
services.extend(
|
|
753
|
+
network_services(service_type, context, desires, copies_per_region)
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
if desires.data_shape.durability_slo_order.mid >= 1000:
|
|
757
|
+
blob = context.services.get("blob.standard", None)
|
|
758
|
+
if blob:
|
|
759
|
+
# Calculate backup disk from desires (same as capacity_plan)
|
|
760
|
+
# This ensures consistent backup costs regardless of how requirement was built
|
|
761
|
+
backup_disk_gib = max(
|
|
762
|
+
1,
|
|
763
|
+
_get_disk_from_desires(desires, copies_per_region)
|
|
764
|
+
// context.zones_in_region,
|
|
765
|
+
)
|
|
766
|
+
services.append(
|
|
767
|
+
ServiceCapacity(
|
|
768
|
+
service_type=f"{service_type}.backup.{blob.name}",
|
|
769
|
+
annual_cost=blob.annual_cost_gib(backup_disk_gib),
|
|
770
|
+
service_params={
|
|
771
|
+
"nines_required": (
|
|
772
|
+
1 - 1.0 / desires.data_shape.durability_slo_order.mid
|
|
773
|
+
)
|
|
774
|
+
},
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
return services
|
|
779
|
+
|
|
780
|
+
@staticmethod
|
|
781
|
+
def cluster_costs(
|
|
782
|
+
service_type: str,
|
|
783
|
+
zonal_clusters: Sequence[ClusterCapacity] = (),
|
|
784
|
+
regional_clusters: Sequence[ClusterCapacity] = (),
|
|
785
|
+
) -> Dict[str, float]:
|
|
786
|
+
return cluster_infra_cost(
|
|
787
|
+
service_type,
|
|
788
|
+
zonal_clusters,
|
|
789
|
+
regional_clusters,
|
|
790
|
+
cluster_type=NflxCassandraCapacityModel.cluster_type,
|
|
791
|
+
)
|
|
792
|
+
|
|
747
793
|
@staticmethod
|
|
748
794
|
def capacity_plan(
|
|
749
795
|
instance: Instance,
|
|
@@ -2,6 +2,8 @@ import logging
|
|
|
2
2
|
import math
|
|
3
3
|
from typing import Any
|
|
4
4
|
from typing import Dict
|
|
5
|
+
from typing import List
|
|
6
|
+
from typing import Sequence
|
|
5
7
|
from typing import Optional
|
|
6
8
|
from typing import Tuple
|
|
7
9
|
|
|
@@ -28,10 +30,14 @@ from service_capacity_modeling.interface import GlobalConsistency
|
|
|
28
30
|
from service_capacity_modeling.interface import Instance
|
|
29
31
|
from service_capacity_modeling.interface import Interval
|
|
30
32
|
from service_capacity_modeling.interface import QueryPattern
|
|
33
|
+
from service_capacity_modeling.interface import ClusterCapacity
|
|
31
34
|
from service_capacity_modeling.interface import RegionContext
|
|
32
35
|
from service_capacity_modeling.interface import Requirements
|
|
36
|
+
from service_capacity_modeling.interface import ServiceCapacity
|
|
33
37
|
from service_capacity_modeling.models import CapacityModel
|
|
38
|
+
from service_capacity_modeling.models import CostAwareModel
|
|
34
39
|
from service_capacity_modeling.models.common import buffer_for_components
|
|
40
|
+
from service_capacity_modeling.models.common import cluster_infra_cost
|
|
35
41
|
from service_capacity_modeling.models.common import compute_stateful_zone
|
|
36
42
|
from service_capacity_modeling.models.common import get_effective_disk_per_node_gib
|
|
37
43
|
from service_capacity_modeling.models.common import network_services
|
|
@@ -330,36 +336,29 @@ def _estimate_evcache_cluster_zonal( # noqa: C901,E501 pylint: disable=too-many
|
|
|
330
336
|
if cluster.count > (max_regional_size // copies_per_region):
|
|
331
337
|
return None
|
|
332
338
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
services.extend(
|
|
344
|
-
network_services("evcache", context, modified, copies_per_region)
|
|
345
|
-
)
|
|
346
|
-
|
|
347
|
-
ec2_cost = copies_per_region * cluster.annual_cost
|
|
348
|
-
spread_cost = calculate_spread_cost(cluster.count)
|
|
339
|
+
# Calculate service costs (network transfer) using the model's service_costs method
|
|
340
|
+
services = NflxEVCacheCapacityModel.service_costs(
|
|
341
|
+
service_type=NflxEVCacheCapacityModel.service_name,
|
|
342
|
+
context=context,
|
|
343
|
+
desires=desires,
|
|
344
|
+
extra_model_arguments={
|
|
345
|
+
"copies_per_region": copies_per_region,
|
|
346
|
+
"cross_region_replication": cross_region_replication.value,
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
349
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
"evcache.zonal-clusters": ec2_cost,
|
|
353
|
-
"evcache.spread.cost": spread_cost,
|
|
354
|
-
}
|
|
350
|
+
cluster.cluster_type = NflxEVCacheCapacityModel.cluster_type
|
|
351
|
+
zonal_clusters = [cluster] * copies_per_region
|
|
355
352
|
|
|
356
|
-
|
|
357
|
-
|
|
353
|
+
evcache_costs = NflxEVCacheCapacityModel.cluster_costs(
|
|
354
|
+
service_type=NflxEVCacheCapacityModel.service_name,
|
|
355
|
+
zonal_clusters=zonal_clusters,
|
|
356
|
+
)
|
|
357
|
+
evcache_costs.update({s.service_type: s.annual_cost for s in services})
|
|
358
358
|
|
|
359
|
-
cluster.cluster_type = "evcache"
|
|
360
359
|
clusters = Clusters(
|
|
361
360
|
annual_costs=evcache_costs,
|
|
362
|
-
zonal=
|
|
361
|
+
zonal=zonal_clusters,
|
|
363
362
|
regional=[],
|
|
364
363
|
services=services,
|
|
365
364
|
)
|
|
@@ -399,7 +398,69 @@ class NflxEVCacheArguments(BaseModel):
|
|
|
399
398
|
)
|
|
400
399
|
|
|
401
400
|
|
|
402
|
-
class NflxEVCacheCapacityModel(CapacityModel):
|
|
401
|
+
class NflxEVCacheCapacityModel(CapacityModel, CostAwareModel):
|
|
402
|
+
service_name = "evcache"
|
|
403
|
+
cluster_type = "evcache"
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def cluster_costs(
|
|
407
|
+
service_type: str,
|
|
408
|
+
zonal_clusters: Sequence[ClusterCapacity] = (),
|
|
409
|
+
regional_clusters: Sequence[ClusterCapacity] = (),
|
|
410
|
+
) -> Dict[str, float]:
|
|
411
|
+
# Adds "{service_type}.spread.cost" penalty for small clusters
|
|
412
|
+
filtered_zonal = [
|
|
413
|
+
c
|
|
414
|
+
for c in zonal_clusters
|
|
415
|
+
if c.cluster_type == NflxEVCacheCapacityModel.cluster_type
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
costs = cluster_infra_cost(
|
|
419
|
+
service_type,
|
|
420
|
+
filtered_zonal,
|
|
421
|
+
regional_clusters,
|
|
422
|
+
cluster_type=NflxEVCacheCapacityModel.cluster_type,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Add spread cost penalty for small clusters
|
|
426
|
+
if filtered_zonal:
|
|
427
|
+
cluster_count = filtered_zonal[0].count
|
|
428
|
+
costs[f"{service_type}.spread.cost"] = calculate_spread_cost(cluster_count)
|
|
429
|
+
|
|
430
|
+
return costs
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def service_costs(
|
|
434
|
+
service_type: str,
|
|
435
|
+
context: RegionContext,
|
|
436
|
+
desires: CapacityDesires,
|
|
437
|
+
extra_model_arguments: Dict[str, Any],
|
|
438
|
+
) -> List[ServiceCapacity]:
|
|
439
|
+
# Network costs depend on cross_region_replication mode:
|
|
440
|
+
# - 'none': No network costs (default)
|
|
441
|
+
# - 'sets': Full write size replicated cross-region
|
|
442
|
+
# - 'evicts': Only 128-byte keys replicated (DELETE operations)
|
|
443
|
+
# Default to 'none' for composite models (like Key-Value) that compose
|
|
444
|
+
# EVCache without specifying cross_region_replication
|
|
445
|
+
cross_region_replication = Replication(
|
|
446
|
+
extra_model_arguments.get("cross_region_replication", "none")
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
match cross_region_replication:
|
|
450
|
+
case Replication.sets:
|
|
451
|
+
copies: int = extra_model_arguments["copies_per_region"]
|
|
452
|
+
return network_services(service_type, context, desires, copies)
|
|
453
|
+
case Replication.evicts:
|
|
454
|
+
copies = extra_model_arguments["copies_per_region"]
|
|
455
|
+
# For evicts mode, only replicate 128-byte keys (DELETE operations)
|
|
456
|
+
modified = desires.model_copy(deep=True)
|
|
457
|
+
modified.query_pattern.estimated_mean_write_size_bytes = certain_int(
|
|
458
|
+
128
|
|
459
|
+
)
|
|
460
|
+
return network_services(service_type, context, modified, copies)
|
|
461
|
+
case Replication.none:
|
|
462
|
+
return []
|
|
463
|
+
|
|
403
464
|
@staticmethod
|
|
404
465
|
def capacity_plan(
|
|
405
466
|
instance: Instance,
|
|
@@ -2,7 +2,9 @@ import logging
|
|
|
2
2
|
import math
|
|
3
3
|
from typing import Any
|
|
4
4
|
from typing import Dict
|
|
5
|
+
from typing import List
|
|
5
6
|
from typing import Optional
|
|
7
|
+
from typing import Sequence
|
|
6
8
|
from typing import Tuple
|
|
7
9
|
|
|
8
10
|
from pydantic import BaseModel
|
|
@@ -17,6 +19,7 @@ from service_capacity_modeling.interface import Buffers
|
|
|
17
19
|
from service_capacity_modeling.interface import CapacityDesires
|
|
18
20
|
from service_capacity_modeling.interface import CapacityPlan
|
|
19
21
|
from service_capacity_modeling.interface import CapacityRequirement
|
|
22
|
+
from service_capacity_modeling.interface import ClusterCapacity
|
|
20
23
|
from service_capacity_modeling.interface import certain_float
|
|
21
24
|
from service_capacity_modeling.interface import certain_int
|
|
22
25
|
from service_capacity_modeling.interface import Clusters
|
|
@@ -33,8 +36,11 @@ from service_capacity_modeling.interface import MIB_IN_BYTES
|
|
|
33
36
|
from service_capacity_modeling.interface import QueryPattern
|
|
34
37
|
from service_capacity_modeling.interface import RegionContext
|
|
35
38
|
from service_capacity_modeling.interface import Requirements
|
|
39
|
+
from service_capacity_modeling.interface import ServiceCapacity
|
|
36
40
|
from service_capacity_modeling.models import CapacityModel
|
|
41
|
+
from service_capacity_modeling.models import CostAwareModel
|
|
37
42
|
from service_capacity_modeling.models.common import buffer_for_components
|
|
43
|
+
from service_capacity_modeling.models.common import cluster_infra_cost
|
|
38
44
|
from service_capacity_modeling.models.common import compute_stateful_zone
|
|
39
45
|
from service_capacity_modeling.models.common import get_effective_disk_per_node_gib
|
|
40
46
|
from service_capacity_modeling.models.common import normalize_cores
|
|
@@ -388,15 +394,18 @@ def _estimate_kafka_cluster_zonal( # noqa: C901
|
|
|
388
394
|
if cluster.count > (max_regional_size // zones_per_region):
|
|
389
395
|
return None
|
|
390
396
|
|
|
391
|
-
|
|
397
|
+
cluster.cluster_type = NflxKafkaCapacityModel.cluster_type
|
|
398
|
+
zonal_clusters = [cluster] * zones_per_region
|
|
392
399
|
|
|
393
400
|
# Account for the clusters and replication costs
|
|
394
|
-
kafka_costs =
|
|
401
|
+
kafka_costs = NflxKafkaCapacityModel.cluster_costs(
|
|
402
|
+
service_type=NflxKafkaCapacityModel.service_name,
|
|
403
|
+
zonal_clusters=zonal_clusters,
|
|
404
|
+
)
|
|
395
405
|
|
|
396
|
-
cluster.cluster_type = "kafka"
|
|
397
406
|
clusters = Clusters(
|
|
398
407
|
annual_costs=kafka_costs,
|
|
399
|
-
zonal=
|
|
408
|
+
zonal=zonal_clusters,
|
|
400
409
|
regional=[],
|
|
401
410
|
services=[],
|
|
402
411
|
)
|
|
@@ -464,7 +473,9 @@ class NflxKafkaArguments(BaseModel):
|
|
|
464
473
|
)
|
|
465
474
|
|
|
466
475
|
|
|
467
|
-
class NflxKafkaCapacityModel(CapacityModel):
|
|
476
|
+
class NflxKafkaCapacityModel(CapacityModel, CostAwareModel):
|
|
477
|
+
service_name = "kafka"
|
|
478
|
+
cluster_type = "kafka"
|
|
468
479
|
HA_DEFAULT_REPLICATION_FACTOR = 2
|
|
469
480
|
SC_DEFAULT_REPLICATION_FACTOR = 3
|
|
470
481
|
|
|
@@ -537,6 +548,29 @@ class NflxKafkaCapacityModel(CapacityModel):
|
|
|
537
548
|
require_same_instance_family=require_same_instance_family,
|
|
538
549
|
)
|
|
539
550
|
|
|
551
|
+
@staticmethod
|
|
552
|
+
def cluster_costs(
|
|
553
|
+
service_type: str,
|
|
554
|
+
zonal_clusters: Sequence[ClusterCapacity] = (),
|
|
555
|
+
regional_clusters: Sequence[ClusterCapacity] = (),
|
|
556
|
+
) -> Dict[str, float]:
|
|
557
|
+
return cluster_infra_cost(
|
|
558
|
+
service_type,
|
|
559
|
+
zonal_clusters,
|
|
560
|
+
regional_clusters,
|
|
561
|
+
cluster_type=NflxKafkaCapacityModel.cluster_type,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
@staticmethod
|
|
565
|
+
def service_costs(
|
|
566
|
+
service_type: str,
|
|
567
|
+
context: RegionContext,
|
|
568
|
+
desires: CapacityDesires,
|
|
569
|
+
extra_model_arguments: Dict[str, Any],
|
|
570
|
+
) -> List[ServiceCapacity]:
|
|
571
|
+
_ = (service_type, context, desires, extra_model_arguments)
|
|
572
|
+
return []
|
|
573
|
+
|
|
540
574
|
@staticmethod
|
|
541
575
|
def description() -> str:
|
|
542
576
|
return "Netflix Streaming Kafka Model"
|
|
@@ -1,14 +1,18 @@
|
|
|
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 .stateless_java import nflx_java_app_capacity_model
|
|
10
|
+
from .stateless_java import NflxJavaAppCapacityModel
|
|
8
11
|
from service_capacity_modeling.interface import AccessConsistency
|
|
9
12
|
from service_capacity_modeling.interface import AccessPattern
|
|
10
13
|
from service_capacity_modeling.interface import CapacityDesires
|
|
11
14
|
from service_capacity_modeling.interface import CapacityPlan
|
|
15
|
+
from service_capacity_modeling.interface import ClusterCapacity
|
|
12
16
|
from service_capacity_modeling.interface import Consistency
|
|
13
17
|
from service_capacity_modeling.interface import DataShape
|
|
14
18
|
from service_capacity_modeling.interface import Drive
|
|
@@ -18,10 +22,16 @@ from service_capacity_modeling.interface import Instance
|
|
|
18
22
|
from service_capacity_modeling.interface import Interval
|
|
19
23
|
from service_capacity_modeling.interface import QueryPattern
|
|
20
24
|
from service_capacity_modeling.interface import RegionContext
|
|
25
|
+
from service_capacity_modeling.interface import ServiceCapacity
|
|
21
26
|
from service_capacity_modeling.models import CapacityModel
|
|
27
|
+
from service_capacity_modeling.models import CostAwareModel
|
|
28
|
+
from service_capacity_modeling.models.common import cluster_infra_cost
|
|
22
29
|
|
|
23
30
|
|
|
24
|
-
class NflxKeyValueCapacityModel(CapacityModel):
|
|
31
|
+
class NflxKeyValueCapacityModel(CapacityModel, CostAwareModel):
|
|
32
|
+
service_name = "key-value"
|
|
33
|
+
cluster_type = "dgwkv"
|
|
34
|
+
|
|
25
35
|
@staticmethod
|
|
26
36
|
def capacity_plan(
|
|
27
37
|
instance: Instance,
|
|
@@ -44,7 +54,7 @@ class NflxKeyValueCapacityModel(CapacityModel):
|
|
|
44
54
|
return None
|
|
45
55
|
|
|
46
56
|
for cluster in kv_app.candidate_clusters.regional:
|
|
47
|
-
cluster.cluster_type =
|
|
57
|
+
cluster.cluster_type = NflxKeyValueCapacityModel.cluster_type
|
|
48
58
|
return kv_app
|
|
49
59
|
|
|
50
60
|
@staticmethod
|
|
@@ -122,6 +132,7 @@ class NflxKeyValueCapacityModel(CapacityModel):
|
|
|
122
132
|
def default_desires(
|
|
123
133
|
user_desires: CapacityDesires, extra_model_arguments: Dict[str, Any]
|
|
124
134
|
) -> CapacityDesires:
|
|
135
|
+
_ = extra_model_arguments
|
|
125
136
|
if user_desires.query_pattern.access_pattern == AccessPattern.latency:
|
|
126
137
|
return CapacityDesires(
|
|
127
138
|
query_pattern=QueryPattern(
|
|
@@ -225,5 +236,36 @@ class NflxKeyValueCapacityModel(CapacityModel):
|
|
|
225
236
|
),
|
|
226
237
|
)
|
|
227
238
|
|
|
239
|
+
@staticmethod
|
|
240
|
+
def cluster_costs(
|
|
241
|
+
service_type: str,
|
|
242
|
+
zonal_clusters: Sequence[ClusterCapacity] = (),
|
|
243
|
+
regional_clusters: Sequence[ClusterCapacity] = (),
|
|
244
|
+
) -> Dict[str, float]:
|
|
245
|
+
# Uses NflxJavaAppCapacityModel.service_name (not service_type param)
|
|
246
|
+
# because capacity_plan delegates to nflx_java_app_capacity_model
|
|
247
|
+
_ = service_type
|
|
248
|
+
return cluster_infra_cost(
|
|
249
|
+
service_type=NflxJavaAppCapacityModel.service_name,
|
|
250
|
+
zonal_clusters=zonal_clusters,
|
|
251
|
+
regional_clusters=regional_clusters,
|
|
252
|
+
cluster_type=NflxKeyValueCapacityModel.cluster_type,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def service_costs(
|
|
257
|
+
service_type: str,
|
|
258
|
+
context: RegionContext,
|
|
259
|
+
desires: CapacityDesires,
|
|
260
|
+
extra_model_arguments: Dict[str, Any],
|
|
261
|
+
) -> List[ServiceCapacity]:
|
|
262
|
+
# Returns empty - dgwkv has no direct network costs:
|
|
263
|
+
# - DataStax driver selects local Cassandra coordinators (same AZ = free)
|
|
264
|
+
# - Coordinator→replica fan-out is counted in cassandra.net.intra.region
|
|
265
|
+
# - EVCache access uses local nodes (same AZ = free)
|
|
266
|
+
# Cassandra/EVCache service costs come from _sub_models() DAG traversal.
|
|
267
|
+
_ = (service_type, context, desires, extra_model_arguments)
|
|
268
|
+
return []
|
|
269
|
+
|
|
228
270
|
|
|
229
271
|
nflx_key_value_capacity_model = NflxKeyValueCapacityModel()
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import math
|
|
2
2
|
from typing import Any
|
|
3
3
|
from typing import Dict
|
|
4
|
+
from typing import List
|
|
4
5
|
from typing import Optional
|
|
6
|
+
from typing import Sequence
|
|
5
7
|
|
|
6
8
|
from pydantic import BaseModel
|
|
7
9
|
from pydantic import Field
|
|
@@ -14,6 +16,7 @@ from service_capacity_modeling.interface import CapacityRegretParameters
|
|
|
14
16
|
from service_capacity_modeling.interface import CapacityRequirement
|
|
15
17
|
from service_capacity_modeling.interface import certain_float
|
|
16
18
|
from service_capacity_modeling.interface import certain_int
|
|
19
|
+
from service_capacity_modeling.interface import ClusterCapacity
|
|
17
20
|
from service_capacity_modeling.interface import Clusters
|
|
18
21
|
from service_capacity_modeling.interface import Consistency
|
|
19
22
|
from service_capacity_modeling.interface import DataShape
|
|
@@ -26,7 +29,10 @@ from service_capacity_modeling.interface import QueryPattern
|
|
|
26
29
|
from service_capacity_modeling.interface import RegionClusterCapacity
|
|
27
30
|
from service_capacity_modeling.interface import RegionContext
|
|
28
31
|
from service_capacity_modeling.interface import Requirements
|
|
32
|
+
from service_capacity_modeling.interface import ServiceCapacity
|
|
29
33
|
from service_capacity_modeling.models import CapacityModel
|
|
34
|
+
from service_capacity_modeling.models import CostAwareModel
|
|
35
|
+
from service_capacity_modeling.models.common import cluster_infra_cost
|
|
30
36
|
from service_capacity_modeling.models.common import compute_stateless_region
|
|
31
37
|
from service_capacity_modeling.models.common import network_services
|
|
32
38
|
from service_capacity_modeling.models.common import normalize_cores
|
|
@@ -110,24 +116,25 @@ def _estimate_java_app_region( # pylint: disable=too-many-positional-arguments
|
|
|
110
116
|
needed_network_mbps=requirement.network_mbps.mid,
|
|
111
117
|
num_zones=zones_per_region,
|
|
112
118
|
)
|
|
113
|
-
cluster.cluster_type =
|
|
119
|
+
cluster.cluster_type = NflxJavaAppCapacityModel.cluster_type
|
|
114
120
|
cluster.attached_drives = attached_drives
|
|
115
121
|
|
|
116
|
-
# Add drive cost (root volume is EBS and costs money)
|
|
117
|
-
drive_cost = sum(d.annual_cost for d in attached_drives) * cluster.count
|
|
118
|
-
cluster.annual_cost = cluster.annual_cost + drive_cost
|
|
119
|
-
|
|
120
122
|
# Generally don't want giant clusters
|
|
121
123
|
# Especially not above 1000 because some load balancers struggle
|
|
122
124
|
# with such large clusters
|
|
123
125
|
|
|
124
126
|
if cluster.count <= 256:
|
|
125
|
-
costs =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
costs = NflxJavaAppCapacityModel.cluster_costs(
|
|
128
|
+
service_type=NflxJavaAppCapacityModel.service_name,
|
|
129
|
+
regional_clusters=[cluster],
|
|
130
|
+
)
|
|
131
|
+
services = NflxJavaAppCapacityModel.service_costs(
|
|
132
|
+
service_type=NflxJavaAppCapacityModel.service_name,
|
|
133
|
+
context=context,
|
|
134
|
+
desires=desires,
|
|
135
|
+
extra_model_arguments={},
|
|
129
136
|
)
|
|
130
|
-
for s in
|
|
137
|
+
for s in services:
|
|
131
138
|
costs[s.service_type] = s.annual_cost
|
|
132
139
|
|
|
133
140
|
return CapacityPlan(
|
|
@@ -136,6 +143,7 @@ def _estimate_java_app_region( # pylint: disable=too-many-positional-arguments
|
|
|
136
143
|
annual_costs=costs,
|
|
137
144
|
regional=[cluster],
|
|
138
145
|
zonal=[],
|
|
146
|
+
services=services,
|
|
139
147
|
),
|
|
140
148
|
)
|
|
141
149
|
return None
|
|
@@ -154,7 +162,41 @@ class NflxJavaAppArguments(BaseModel):
|
|
|
154
162
|
)
|
|
155
163
|
|
|
156
164
|
|
|
157
|
-
class NflxJavaAppCapacityModel(CapacityModel):
|
|
165
|
+
class NflxJavaAppCapacityModel(CapacityModel, CostAwareModel):
|
|
166
|
+
service_name = "nflx-java-app"
|
|
167
|
+
cluster_type = "nflx-java-app"
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def cluster_costs(
|
|
171
|
+
service_type: str,
|
|
172
|
+
zonal_clusters: Sequence[ClusterCapacity] = (),
|
|
173
|
+
regional_clusters: Sequence[ClusterCapacity] = (),
|
|
174
|
+
) -> Dict[str, float]:
|
|
175
|
+
return cluster_infra_cost(
|
|
176
|
+
service_type,
|
|
177
|
+
zonal_clusters,
|
|
178
|
+
regional_clusters,
|
|
179
|
+
cluster_type=NflxJavaAppCapacityModel.cluster_type,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def service_costs(
|
|
184
|
+
service_type: str,
|
|
185
|
+
context: RegionContext,
|
|
186
|
+
desires: CapacityDesires,
|
|
187
|
+
extra_model_arguments: Dict[str, Any],
|
|
188
|
+
) -> List[ServiceCapacity]:
|
|
189
|
+
# TODO(matthewho): Currently returns empty because RegionContext is
|
|
190
|
+
# created without services. Need to determine if stateless apps should
|
|
191
|
+
# have cross-zone costs (copies_per_region=2 implies 1 cross-AZ hop).
|
|
192
|
+
_ = (context, extra_model_arguments)
|
|
193
|
+
return network_services(
|
|
194
|
+
service_type,
|
|
195
|
+
RegionContext(num_regions=1),
|
|
196
|
+
desires,
|
|
197
|
+
copies_per_region=2,
|
|
198
|
+
)
|
|
199
|
+
|
|
158
200
|
@staticmethod
|
|
159
201
|
def capacity_plan(
|
|
160
202
|
instance: Instance,
|
{service_capacity_modeling-0.3.105.dist-info → service_capacity_modeling-0.3.106.dist-info}/RECORD
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
service_capacity_modeling/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
service_capacity_modeling/capacity_planner.py,sha256=
|
|
2
|
+
service_capacity_modeling/capacity_planner.py,sha256=56pGPVwSMv7snqwT0XQiTNSuqFYuWI9cDH9pyzqZwbY,43094
|
|
3
3
|
service_capacity_modeling/enum_utils.py,sha256=50Rw2kgYoJYCrybSbo9WaPPCWxlF5CyPCQtHxQ3kB18,5229
|
|
4
|
-
service_capacity_modeling/interface.py,sha256=
|
|
4
|
+
service_capacity_modeling/interface.py,sha256=nwNRTTr4gD38rprpF9ql1RxMfVK1IeDL1atAVI_87S0,45420
|
|
5
5
|
service_capacity_modeling/stats.py,sha256=LCNUcQPfwF5hhIZwsfAsDe4ZbnuhDnl3vQHKfpK61Xc,6142
|
|
6
6
|
service_capacity_modeling/hardware/__init__.py,sha256=P5ostvoSOMUqPODtepeFYb4qfTVH0E73mMFraP49rYU,9196
|
|
7
7
|
service_capacity_modeling/hardware/profiles/__init__.py,sha256=7-y3JbCBkgzaAjFla2RIymREcImdZ51HTl3yn3vzoGw,1602
|
|
@@ -51,28 +51,28 @@ service_capacity_modeling/hardware/profiles/shapes/aws/auto_r8i.json,sha256=CxRt
|
|
|
51
51
|
service_capacity_modeling/hardware/profiles/shapes/aws/manual_drives.json,sha256=0qxEciNTb0yGhAmX1bI6hV-4SSkGMp1FZ8OQvaOET64,1709
|
|
52
52
|
service_capacity_modeling/hardware/profiles/shapes/aws/manual_instances.json,sha256=-6Nsy-LlDuxm6LNp-hm7LkEf_6yGRxNSqU77pFtFHeY,12457
|
|
53
53
|
service_capacity_modeling/hardware/profiles/shapes/aws/manual_services.json,sha256=h63675KKmu5IrI3BORDN8fiAqLjAyYHArErKbC7-T30,776
|
|
54
|
-
service_capacity_modeling/models/__init__.py,sha256=
|
|
55
|
-
service_capacity_modeling/models/common.py,sha256=
|
|
54
|
+
service_capacity_modeling/models/__init__.py,sha256=MbnmdVfxDJFVtS5d6GK567RSa5V086oEDX-tJtB68WA,15494
|
|
55
|
+
service_capacity_modeling/models/common.py,sha256=6a2ar_0lrrXKZtBAFKl7ETmd2Vp7lneH2C9eXUy0TBM,37713
|
|
56
56
|
service_capacity_modeling/models/headroom_strategy.py,sha256=rGo_d7nxkQDjx0_hIAXKKZAWnQDBtqZhc0eTMouVh8s,682
|
|
57
57
|
service_capacity_modeling/models/utils.py,sha256=WlBaU9l11V5atThMTWDV9-FT1uf0FJS_2iyu8RF5NIk,2665
|
|
58
58
|
service_capacity_modeling/models/org/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
59
59
|
service_capacity_modeling/models/org/netflix/__init__.py,sha256=keaBt7dk6DB2VuRINdo8wRfsobK655Gfw3hYjruacJs,2553
|
|
60
|
-
service_capacity_modeling/models/org/netflix/aurora.py,sha256=
|
|
61
|
-
service_capacity_modeling/models/org/netflix/cassandra.py,sha256=
|
|
60
|
+
service_capacity_modeling/models/org/netflix/aurora.py,sha256=I-V89y5AYq9SfQ1ntpUWQUP9Sb4XNfNBOADXPYODHY8,13412
|
|
61
|
+
service_capacity_modeling/models/org/netflix/cassandra.py,sha256=4jnxoDcPLm1y-kwYtuF27hr8aWfpjsCui098UvInsIA,41336
|
|
62
62
|
service_capacity_modeling/models/org/netflix/control.py,sha256=4F9yw60mnOvLrhzRwJwk6kGDBB861a9GhPCNGcC9_Ho,5774
|
|
63
63
|
service_capacity_modeling/models/org/netflix/counter.py,sha256=kTDL7dCnkn-XU27_Z1VBc4CCLCPoOqJZe9WgcENHHd4,10517
|
|
64
64
|
service_capacity_modeling/models/org/netflix/crdb.py,sha256=iW7tyG8jpXhHIdXrw3DPYSHRAknPN42MlCRLJO4o9C8,20826
|
|
65
65
|
service_capacity_modeling/models/org/netflix/ddb.py,sha256=9qRiuTqWev9zbYFFzewyowU7M41uALsuLklYx20yAXw,26502
|
|
66
66
|
service_capacity_modeling/models/org/netflix/elasticsearch.py,sha256=zPrC6b2LNrAh3IWE3HCMUEYASacjYbHChbO4WZSMma4,25234
|
|
67
67
|
service_capacity_modeling/models/org/netflix/entity.py,sha256=CrexndRmoVA_082XYMIL9LVM13qqI8ILJUPbzH6uYZY,8858
|
|
68
|
-
service_capacity_modeling/models/org/netflix/evcache.py,sha256=
|
|
68
|
+
service_capacity_modeling/models/org/netflix/evcache.py,sha256=I4V2AAtK_nZj6KVLX3QMWfG7fP_1Xq12F9tIqkwQ6rk,28215
|
|
69
69
|
service_capacity_modeling/models/org/netflix/graphkv.py,sha256=7ncEhx9lLsN_vGIKNHkvWfDdKffG7cYe91Wr-DB7IjU,8659
|
|
70
70
|
service_capacity_modeling/models/org/netflix/iso_date_math.py,sha256=oC5sgIXDqwOp6-5z2bdTkm-bJLlnzhqcONI_tspHjac,1137
|
|
71
|
-
service_capacity_modeling/models/org/netflix/kafka.py,sha256=
|
|
72
|
-
service_capacity_modeling/models/org/netflix/key_value.py,sha256=
|
|
71
|
+
service_capacity_modeling/models/org/netflix/kafka.py,sha256=pkq9e0O3K6Nh_4V9aOuczlKFfSBW-bjhgkMwJ7b3bTg,27025
|
|
72
|
+
service_capacity_modeling/models/org/netflix/key_value.py,sha256=Z-En08IgwJ9e0rgszH-6a9hy3SxfzGGAf4oEDk_aqvs,11235
|
|
73
73
|
service_capacity_modeling/models/org/netflix/postgres.py,sha256=LBxDqkc-lYxDBu2VwNLuf2Q4o4hU3jPwu4YSt33Oe-8,4128
|
|
74
74
|
service_capacity_modeling/models/org/netflix/rds.py,sha256=8GVmpMhTisZPdT-mP1Sx5U7VAF32lnTI27iYPfGg9CY,10930
|
|
75
|
-
service_capacity_modeling/models/org/netflix/stateless_java.py,sha256
|
|
75
|
+
service_capacity_modeling/models/org/netflix/stateless_java.py,sha256=wiZ7bPEEv-fMePkB7q_LaoVRsYdtVbJiA64UZn_K7EA,13014
|
|
76
76
|
service_capacity_modeling/models/org/netflix/time_series.py,sha256=NjZTr0NC6c0tduY4O1Z6Nfm0S8Mt0pKxPgphywIulvQ,8240
|
|
77
77
|
service_capacity_modeling/models/org/netflix/time_series_config.py,sha256=usV7y9NVb8hfGrB6POg63lzSfxUafyBMu0zP-HvPOMo,7326
|
|
78
78
|
service_capacity_modeling/models/org/netflix/wal.py,sha256=QtRlqP_AIVpTg-XEINAfvf7J7J9EzXMY5PrxE3DIOU0,4482
|
|
@@ -84,9 +84,9 @@ service_capacity_modeling/tools/fetch_pricing.py,sha256=fO84h77cqiiIHF4hZt490Rwb
|
|
|
84
84
|
service_capacity_modeling/tools/generate_missing.py,sha256=F7YqvMJAV4nZc20GNrlIsnQSF8_77sLgwYZqc5k4LDg,3099
|
|
85
85
|
service_capacity_modeling/tools/instance_families.py,sha256=e5RuYkCLUITvsAazDH12B6KjX_PaBsv6Ne3mj0HK_sQ,9223
|
|
86
86
|
service_capacity_modeling/tools/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
87
|
-
service_capacity_modeling-0.3.
|
|
88
|
-
service_capacity_modeling-0.3.
|
|
89
|
-
service_capacity_modeling-0.3.
|
|
90
|
-
service_capacity_modeling-0.3.
|
|
91
|
-
service_capacity_modeling-0.3.
|
|
92
|
-
service_capacity_modeling-0.3.
|
|
87
|
+
service_capacity_modeling-0.3.106.dist-info/licenses/LICENSE,sha256=nl_Lt5v9VvJ-5lWJDT4ddKAG-VZ-2IaLmbzpgYDz2hU,11343
|
|
88
|
+
service_capacity_modeling-0.3.106.dist-info/METADATA,sha256=_SvhiphVKCZqoHRPWFVckzh0W7xhSLNz04focrlzDLw,10367
|
|
89
|
+
service_capacity_modeling-0.3.106.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
90
|
+
service_capacity_modeling-0.3.106.dist-info/entry_points.txt,sha256=ZsjzpG5SomWpT1zCE19n1uSXKH2gTI_yc33sdl0vmJg,146
|
|
91
|
+
service_capacity_modeling-0.3.106.dist-info/top_level.txt,sha256=H8XjTCLgR3enHq5t3bIbxt9SeUkUT8HT_SDv2dgIT_A,26
|
|
92
|
+
service_capacity_modeling-0.3.106.dist-info/RECORD,,
|
{service_capacity_modeling-0.3.105.dist-info → service_capacity_modeling-0.3.106.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|