|
31 | 31 |
|
32 | 32 | RESOURCE_GROUP_ID = ResourceGroupID(uuid4()) |
33 | 33 |
|
| 34 | +_USER_A = UUID("11111111-1111-4111-8111-111111111111") |
| 35 | +_USER_B = UUID("22222222-2222-4222-8222-222222222222") |
| 36 | +_USER_C = UUID("33333333-3333-4333-8333-333333333333") |
| 37 | +_PROJECT_1 = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") |
| 38 | +_PROJECT_2 = UUID("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") |
| 39 | +_TICK_DAY = date(2026, 7, 17) |
| 40 | + |
34 | 41 |
|
35 | 42 | def make_datetime( |
36 | 43 | year: int, month: int, day: int, hour: int, minute: int, second: int = 0 |
@@ -830,3 +837,181 @@ def test_full_day_of_four_kernels(self, aggregator: FairShareAggregator) -> None |
830 | 837 | ) |
831 | 838 | delta = result.user_usage_deltas[user_key] |
832 | 839 | assert delta.resource_seconds["cuda.shares"] == Decimal("345600") |
| 840 | + |
| 841 | + |
| 842 | +@dataclass(frozen=True) |
| 843 | +class _Workload: |
| 844 | + """A group of identical kernels one user runs inside one project.""" |
| 845 | + |
| 846 | + user_uuid: UUID |
| 847 | + project_id: UUID |
| 848 | + shares: Decimal |
| 849 | + kernel_count: int |
| 850 | + |
| 851 | + |
| 852 | +@dataclass(frozen=True) |
| 853 | +class _UserBucketExpectation: |
| 854 | + label: str |
| 855 | + user_uuid: UUID |
| 856 | + project_id: UUID |
| 857 | + resource_seconds: Decimal |
| 858 | + duration_seconds: int |
| 859 | + |
| 860 | + |
| 861 | +@dataclass(frozen=True) |
| 862 | +class _ProjectBucketExpectation: |
| 863 | + label: str |
| 864 | + project_id: UUID |
| 865 | + resource_seconds: Decimal |
| 866 | + duration_seconds: int |
| 867 | + |
| 868 | + |
| 869 | +class TestMultiTenantTick: |
| 870 | + """One observation tick spanning two projects, three users and seven kernels. |
| 871 | +
|
| 872 | + The cross product does not merely double a number, it compounds up the |
| 873 | + hierarchy: each level multiplies by the count of kernels *it* aggregates. |
| 874 | + For this workload the old code produced 1200 instead of 600 for a user |
| 875 | + (2x), 3600 instead of 1200 for a project (3x) and 23100 instead of 3300 |
| 876 | + for the domain (7x), which is why domain figures looked most absurd. |
| 877 | +
|
| 878 | + User A runs in both projects, so the user buckets must stay keyed by |
| 879 | + (user, project) and must not collapse into one. |
| 880 | + """ |
| 881 | + |
| 882 | + @pytest.fixture |
| 883 | + def multi_tenant_specs(self) -> list[KernelUsageRecordCreatorSpec]: |
| 884 | + """One 5-minute slice per kernel, all on the same day.""" |
| 885 | + workloads = [ |
| 886 | + _Workload(_USER_A, _PROJECT_1, Decimal("1"), 2), |
| 887 | + _Workload(_USER_B, _PROJECT_1, Decimal("2"), 1), |
| 888 | + _Workload(_USER_A, _PROJECT_2, Decimal("1"), 3), |
| 889 | + _Workload(_USER_C, _PROJECT_2, Decimal("4"), 1), |
| 890 | + ] |
| 891 | + return [ |
| 892 | + make_spec( |
| 893 | + period_start=make_datetime(2026, 7, 17, 10, 0, 0), |
| 894 | + period_end=make_datetime(2026, 7, 17, 10, 5, 0), |
| 895 | + resource_usage=ResourceSlot({"cuda.shares": workload.shares * 300}), |
| 896 | + occupied_slots=ResourceSlot({"cuda.shares": workload.shares}), |
| 897 | + user_uuid=workload.user_uuid, |
| 898 | + project_id=workload.project_id, |
| 899 | + ) |
| 900 | + for workload in workloads |
| 901 | + for _ in range(workload.kernel_count) |
| 902 | + ] |
| 903 | + |
| 904 | + @pytest.mark.parametrize( |
| 905 | + "case", |
| 906 | + [ |
| 907 | + _UserBucketExpectation( |
| 908 | + label="user-a-project-1", # 2 kernels * 1 share * 300s |
| 909 | + user_uuid=_USER_A, |
| 910 | + project_id=_PROJECT_1, |
| 911 | + resource_seconds=Decimal("600"), |
| 912 | + duration_seconds=600, |
| 913 | + ), |
| 914 | + _UserBucketExpectation( |
| 915 | + label="user-b-project-1", # 1 kernel * 2 shares * 300s |
| 916 | + user_uuid=_USER_B, |
| 917 | + project_id=_PROJECT_1, |
| 918 | + resource_seconds=Decimal("600"), |
| 919 | + duration_seconds=300, |
| 920 | + ), |
| 921 | + _UserBucketExpectation( |
| 922 | + label="user-a-project-2", # 3 kernels * 1 share * 300s |
| 923 | + user_uuid=_USER_A, |
| 924 | + project_id=_PROJECT_2, |
| 925 | + resource_seconds=Decimal("900"), |
| 926 | + duration_seconds=900, |
| 927 | + ), |
| 928 | + _UserBucketExpectation( |
| 929 | + label="user-c-project-2", # 1 kernel * 4 shares * 300s |
| 930 | + user_uuid=_USER_C, |
| 931 | + project_id=_PROJECT_2, |
| 932 | + resource_seconds=Decimal("1200"), |
| 933 | + duration_seconds=300, |
| 934 | + ), |
| 935 | + ], |
| 936 | + ids=lambda case: case.label, |
| 937 | + ) |
| 938 | + def test_user_buckets_are_keyed_by_user_and_project( |
| 939 | + self, |
| 940 | + aggregator: FairShareAggregator, |
| 941 | + multi_tenant_specs: list[KernelUsageRecordCreatorSpec], |
| 942 | + case: _UserBucketExpectation, |
| 943 | + ) -> None: |
| 944 | + result = aggregator.aggregate_kernel_usage_to_buckets(multi_tenant_specs) |
| 945 | + |
| 946 | + assert len(result.user_usage_deltas) == 4 |
| 947 | + delta = result.user_usage_deltas[ |
| 948 | + UserUsageBucketKey( |
| 949 | + user_uuid=case.user_uuid, |
| 950 | + project_id=case.project_id, |
| 951 | + domain_name="default", |
| 952 | + resource_group="default", |
| 953 | + resource_group_id=RESOURCE_GROUP_ID, |
| 954 | + period_date=_TICK_DAY, |
| 955 | + ) |
| 956 | + ] |
| 957 | + assert delta.resource_seconds["cuda.shares"] == case.resource_seconds |
| 958 | + assert delta.duration_seconds == case.duration_seconds |
| 959 | + |
| 960 | + @pytest.mark.parametrize( |
| 961 | + "case", |
| 962 | + [ |
| 963 | + _ProjectBucketExpectation( |
| 964 | + label="project-1", # user A 600 + user B 600 |
| 965 | + project_id=_PROJECT_1, |
| 966 | + resource_seconds=Decimal("1200"), |
| 967 | + duration_seconds=900, |
| 968 | + ), |
| 969 | + _ProjectBucketExpectation( |
| 970 | + label="project-2", # user A 900 + user C 1200 |
| 971 | + project_id=_PROJECT_2, |
| 972 | + resource_seconds=Decimal("2100"), |
| 973 | + duration_seconds=1200, |
| 974 | + ), |
| 975 | + ], |
| 976 | + ids=lambda case: case.label, |
| 977 | + ) |
| 978 | + def test_project_buckets_sum_their_users( |
| 979 | + self, |
| 980 | + aggregator: FairShareAggregator, |
| 981 | + multi_tenant_specs: list[KernelUsageRecordCreatorSpec], |
| 982 | + case: _ProjectBucketExpectation, |
| 983 | + ) -> None: |
| 984 | + result = aggregator.aggregate_kernel_usage_to_buckets(multi_tenant_specs) |
| 985 | + |
| 986 | + assert len(result.project_usage_deltas) == 2 |
| 987 | + delta = result.project_usage_deltas[ |
| 988 | + ProjectUsageBucketKey( |
| 989 | + project_id=case.project_id, |
| 990 | + domain_name="default", |
| 991 | + resource_group="default", |
| 992 | + resource_group_id=RESOURCE_GROUP_ID, |
| 993 | + period_date=_TICK_DAY, |
| 994 | + ) |
| 995 | + ] |
| 996 | + assert delta.resource_seconds["cuda.shares"] == case.resource_seconds |
| 997 | + assert delta.duration_seconds == case.duration_seconds |
| 998 | + |
| 999 | + def test_domain_bucket_sums_every_project( |
| 1000 | + self, |
| 1001 | + aggregator: FairShareAggregator, |
| 1002 | + multi_tenant_specs: list[KernelUsageRecordCreatorSpec], |
| 1003 | + ) -> None: |
| 1004 | + """Project 1 (1200) + project 2 (2100); the old code reported 23100.""" |
| 1005 | + result = aggregator.aggregate_kernel_usage_to_buckets(multi_tenant_specs) |
| 1006 | + |
| 1007 | + assert len(result.domain_usage_deltas) == 1 |
| 1008 | + delta = result.domain_usage_deltas[ |
| 1009 | + DomainUsageBucketKey( |
| 1010 | + domain_name="default", |
| 1011 | + resource_group="default", |
| 1012 | + resource_group_id=RESOURCE_GROUP_ID, |
| 1013 | + period_date=_TICK_DAY, |
| 1014 | + ) |
| 1015 | + ] |
| 1016 | + assert delta.resource_seconds["cuda.shares"] == Decimal("3300") |
| 1017 | + assert delta.duration_seconds == 2100 |
0 commit comments