diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ecfe06de..bc94f52ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## Unreleased - client: break latency ties with avg latency ([#362](https://github.com/malbeclabs/doublezero/pull/3692)) +- e2e/qa: skip the per-type capacity check in `ValidDevices` when the onchain per-type max is zero. The chain treats `max_unicast_users == 0` (and the multicast equivalents) as "no per-type cap," so the QA filter falls back to the aggregate `max_users - users_count` check for those devices. Fixes the regression where `qa.alldevices` on mainnet-beta dropped from testing 85 devices to testing 3 (malbeclabs/infra#1294) ### Breaking diff --git a/e2e/internal/qa/test.go b/e2e/internal/qa/test.go index d3c3c0a8d..699379f3c 100644 --- a/e2e/internal/qa/test.go +++ b/e2e/internal/qa/test.go @@ -127,8 +127,10 @@ func (d *Device) capacityFor(userType DeviceUserType) (current, max int) { // ValidDevices returns devices that pass filtering criteria for the given // user type. A device is considered valid when it has at least minCapacity -// free slots in the type-specific bucket (e.g. unicast) AND in the aggregate -// users bucket — both are enforced onchain independently. +// free slots in the aggregate users bucket. The type-specific bucket +// (e.g. unicast) is also checked, but only when its onchain max is non-zero — +// onchain, a per-type max of 0 means the cap is unenforced (see +// smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs). // // If skipCapacityCheck is true (e.g., when using a QA identity that bypasses // on-chain capacity checks), devices are not filtered by available capacity. @@ -145,7 +147,10 @@ func (t *Test) ValidDevices(userType DeviceUserType, minCapacity int, skipCapaci // Skip capacity check if using QA identity (bypasses on-chain max_users check) if !skipCapacityCheck { typeCount, typeMax := device.capacityFor(userType) - if typeMax-typeCount < minCapacity { + // Mirror the onchain semantic: the per-type cap is only enforced + // when max > 0. A max of 0 means "no per-type cap" and we fall + // through to the aggregate check. + if typeMax > 0 && typeMax-typeCount < minCapacity { t.log.Debug("Skipping device with insufficient type-specific capacity", "device", device.Code, "userType", userType, diff --git a/e2e/internal/qa/test_test.go b/e2e/internal/qa/test_test.go new file mode 100644 index 000000000..4f8a40123 --- /dev/null +++ b/e2e/internal/qa/test_test.go @@ -0,0 +1,209 @@ +package qa + +import ( + "io" + "log/slog" + "testing" + + "github.com/stretchr/testify/require" +) + +// newTestForValidDevices builds a minimal *Test from a slice of devices. +// ValidDevices only depends on t.log and t.devices. +func newTestForValidDevices(devices []*Device) *Test { + deviceMap := make(map[string]*Device, len(devices)) + for _, d := range devices { + deviceMap[d.Code] = d + } + return &Test{ + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + devices: deviceMap, + } +} + +// codesOf returns the sorted codes returned by ValidDevices, for easy +// comparison against expected sets. +func codesOf(devices []*Device) []string { + out := make([]string, 0, len(devices)) + for _, d := range devices { + out = append(out, d.Code) + } + return out +} + +func TestValidDevices_Unicast(t *testing.T) { + t.Parallel() + + const minCapacity = 2 + + tests := []struct { + name string + devices []*Device + skipCapacityCheck bool + want []string + }{ + { + name: "per-type max set with free slots is included", + devices: []*Device{ + {Code: "alpha", MaxUsers: 96, UsersCount: 4, MaxUnicastUsers: 48, UnicastUsersCount: 4}, + }, + want: []string{"alpha"}, + }, + { + name: "per-type max set and saturated is excluded (preserves #3563 fix)", + devices: []*Device{ + {Code: "nyc002-dz002", MaxUsers: 96, UsersCount: 29, MaxUnicastUsers: 29, UnicastUsersCount: 29}, + }, + want: []string{}, + }, + { + name: "per-type max set with fewer than minCapacity free slots is excluded", + devices: []*Device{ + {Code: "bravo", MaxUsers: 96, UsersCount: 47, MaxUnicastUsers: 48, UnicastUsersCount: 47}, + }, + want: []string{}, + }, + { + name: "per-type max zero with users counted is included (regression fix)", + devices: []*Device{ + {Code: "frankfurt-edge", MaxUsers: 96, UsersCount: 12, MaxUnicastUsers: 0, UnicastUsersCount: 12}, + }, + want: []string{"frankfurt-edge"}, + }, + { + name: "per-type max zero with aggregate cap saturated is excluded", + devices: []*Device{ + {Code: "full", MaxUsers: 5, UsersCount: 4, MaxUnicastUsers: 0, UnicastUsersCount: 4}, + }, + want: []string{}, + }, + { + name: "device with test in code is excluded", + devices: []*Device{ + {Code: "lab-test-1", MaxUsers: 96, UsersCount: 0, MaxUnicastUsers: 48, UnicastUsersCount: 0}, + }, + want: []string{}, + }, + { + name: "skipCapacityCheck includes saturated and zero-max devices", + devices: []*Device{ + {Code: "alpha", MaxUsers: 96, UsersCount: 4, MaxUnicastUsers: 48, UnicastUsersCount: 4}, + {Code: "nyc002-dz002", MaxUsers: 96, UsersCount: 29, MaxUnicastUsers: 29, UnicastUsersCount: 29}, + {Code: "frankfurt-edge", MaxUsers: 96, UsersCount: 12, MaxUnicastUsers: 0, UnicastUsersCount: 12}, + {Code: "full", MaxUsers: 5, UsersCount: 4, MaxUnicastUsers: 0, UnicastUsersCount: 4}, + }, + skipCapacityCheck: true, + want: []string{"alpha", "frankfurt-edge", "full", "nyc002-dz002"}, + }, + { + name: "mainnet-beta-like mix returns only those with free per-type or unset cap", + devices: []*Device{ + {Code: "allnodes-fra1", MaxUsers: 96, UsersCount: 29, MaxUnicastUsers: 48, UnicastUsersCount: 29}, + {Code: "fra-velia", MaxUsers: 96, UsersCount: 4, MaxUnicastUsers: 48, UnicastUsersCount: 4}, + {Code: "frankry", MaxUsers: 128, UsersCount: 68, MaxUnicastUsers: 96, UnicastUsersCount: 68}, + {Code: "nyc002-dz002", MaxUsers: 96, UsersCount: 29, MaxUnicastUsers: 29, UnicastUsersCount: 29}, + {Code: "amsterdam-edge", MaxUsers: 96, UsersCount: 7, MaxUnicastUsers: 0, UnicastUsersCount: 7}, + {Code: "tokyo-edge", MaxUsers: 96, UsersCount: 2, MaxUnicastUsers: 0, UnicastUsersCount: 2}, + }, + want: []string{"allnodes-fra1", "amsterdam-edge", "fra-velia", "frankry", "tokyo-edge"}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tt := newTestForValidDevices(tc.devices) + got := codesOf(tt.ValidDevices(DeviceUserTypeUnicast, minCapacity, tc.skipCapacityCheck)) + require.Equal(t, tc.want, got) + }) + } +} + +func TestValidDevices_MulticastPublisher(t *testing.T) { + t.Parallel() + + const minCapacity = 1 + + tests := []struct { + name string + devices []*Device + want []string + }{ + { + name: "per-type max set with free slots is included", + devices: []*Device{ + {Code: "pub-ok", MaxUsers: 96, UsersCount: 0, MaxMulticastPublishers: 4, MulticastPublishersCount: 1}, + }, + want: []string{"pub-ok"}, + }, + { + name: "per-type max set and saturated is excluded", + devices: []*Device{ + {Code: "pub-full", MaxUsers: 96, UsersCount: 0, MaxMulticastPublishers: 1, MulticastPublishersCount: 1}, + }, + want: []string{}, + }, + { + name: "per-type max zero with publishers counted is included (regression fix)", + devices: []*Device{ + {Code: "pub-uncapped", MaxUsers: 96, UsersCount: 3, MaxMulticastPublishers: 0, MulticastPublishersCount: 3}, + }, + want: []string{"pub-uncapped"}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tt := newTestForValidDevices(tc.devices) + got := codesOf(tt.ValidDevices(DeviceUserTypeMulticastPublisher, minCapacity, false)) + require.Equal(t, tc.want, got) + }) + } +} + +func TestValidDevices_MulticastSubscriber(t *testing.T) { + t.Parallel() + + const minCapacity = 1 + + tests := []struct { + name string + devices []*Device + want []string + }{ + { + name: "per-type max set with free slots is included", + devices: []*Device{ + {Code: "sub-ok", MaxUsers: 96, UsersCount: 0, MaxMulticastSubscribers: 8, MulticastSubscribersCount: 2}, + }, + want: []string{"sub-ok"}, + }, + { + name: "per-type max set and saturated is excluded", + devices: []*Device{ + {Code: "sub-full", MaxUsers: 96, UsersCount: 0, MaxMulticastSubscribers: 2, MulticastSubscribersCount: 2}, + }, + want: []string{}, + }, + { + name: "per-type max zero with subscribers counted is included (regression fix)", + devices: []*Device{ + {Code: "sub-uncapped", MaxUsers: 96, UsersCount: 5, MaxMulticastSubscribers: 0, MulticastSubscribersCount: 5}, + }, + want: []string{"sub-uncapped"}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tt := newTestForValidDevices(tc.devices) + got := codesOf(tt.ValidDevices(DeviceUserTypeMulticastSubscriber, minCapacity, false)) + require.Equal(t, tc.want, got) + }) + } +}