-
Notifications
You must be signed in to change notification settings - Fork 3
api: derive multicast subscriber/publisher counts from live users #653
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| package handlers_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "github.com/go-chi/chi/v5" | ||
| "github.com/malbeclabs/lake/api/handlers" | ||
| apitesting "github.com/malbeclabs/lake/api/testing" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // insertContributorMulticastData inserts a contributor with one device whose | ||
| // on-chain multicast counts are left at 0, plus activated multicast users that | ||
| // subscribe to / publish to groups. The contributor detail must report the live | ||
| // per-device counts, not the stale on-chain zeros (#650). | ||
| func insertContributorMulticastData(t *testing.T, api *handlers.API) { | ||
| ctx := t.Context() | ||
|
|
||
| err := api.DB.Exec(ctx, ` | ||
| INSERT INTO dim_dz_contributors_history | ||
| (entity_id, snapshot_ts, ingested_at, op_id, is_deleted, attrs_hash, pk, code, name) VALUES | ||
| ('c-mc', now(), now(), generateUUIDv4(), 0, 1, 'c-mc', 'CMC', 'Multicast Contributor') | ||
| `) | ||
| require.NoError(t, err) | ||
|
|
||
| err = api.DB.Exec(ctx, ` | ||
| INSERT INTO dim_dz_devices_history | ||
| (entity_id, snapshot_ts, ingested_at, op_id, is_deleted, attrs_hash, pk, code, status, device_type, contributor_pk, metro_pk, public_ip, max_users) VALUES | ||
| ('cdev-1', now(), now(), generateUUIDv4(), 0, 1, 'cdev-1', 'C-DEV-01', 'up', 'switch', 'c-mc', '', '10.8.0.1', 100) | ||
| `) | ||
| require.NoError(t, err) | ||
|
|
||
| // 3 subscriber-only, 2 publisher-only, 1 both-roles, 1 pending (excluded). | ||
| // Live totals: subscribers 4, publishers 3. | ||
| err = api.DB.Exec(ctx, ` | ||
| INSERT INTO dim_dz_users_history | ||
| (entity_id, snapshot_ts, ingested_at, op_id, is_deleted, attrs_hash, pk, status, device_pk, kind, owner_pubkey, client_ip, dz_ip, tunnel_id, publishers, subscribers) VALUES | ||
| ('c-s1', now(), now(), generateUUIDv4(), 0, 1, 'c-s1', 'activated', 'cdev-1', 'multicast', 'p', '', '', 0, '[]', '["g1"]'), | ||
| ('c-s2', now(), now(), generateUUIDv4(), 0, 2, 'c-s2', 'activated', 'cdev-1', 'multicast', 'p', '', '', 0, '[]', '["g1"]'), | ||
| ('c-s3', now(), now(), generateUUIDv4(), 0, 3, 'c-s3', 'activated', 'cdev-1', 'multicast', 'p', '', '', 0, '[]', '["g2"]'), | ||
| ('c-p1', now(), now(), generateUUIDv4(), 0, 4, 'c-p1', 'activated', 'cdev-1', 'multicast', 'p', '', '', 0, '["g1"]', '[]'), | ||
| ('c-p2', now(), now(), generateUUIDv4(), 0, 5, 'c-p2', 'activated', 'cdev-1', 'multicast', 'p', '', '', 0, '["g1"]', '[]'), | ||
| ('c-b1', now(), now(), generateUUIDv4(), 0, 6, 'c-b1', 'activated', 'cdev-1', 'multicast', 'p', '', '', 0, '["g2"]', '["g2"]'), | ||
| ('c-pend', now(), now(), generateUUIDv4(), 0, 7, 'c-pend', 'pending', 'cdev-1', 'multicast', 'p', '', '', 0, '[]', '["g1"]') | ||
| `) | ||
| require.NoError(t, err) | ||
| } | ||
|
|
||
| func TestGetContributor_MulticastCountsFromLiveUsers(t *testing.T) { | ||
| t.Parallel() | ||
| api := apitesting.NewTestAPI(t, testChDB) | ||
|
|
||
| insertContributorMulticastData(t, api) | ||
|
|
||
| req := httptest.NewRequest(http.MethodGet, "/api/dz/contributors/c-mc", nil) | ||
| rctx := chi.NewRouteContext() | ||
| rctx.URLParams.Add("pk", "c-mc") | ||
| req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) | ||
|
|
||
| rr := httptest.NewRecorder() | ||
| api.GetContributor(rr, req) | ||
| require.Equal(t, http.StatusOK, rr.Code) | ||
|
|
||
| var contributor handlers.ContributorDetail | ||
| require.NoError(t, json.NewDecoder(rr.Body).Decode(&contributor)) | ||
| assert.Equal(t, uint64(4), contributor.MulticastSubscribersCount) | ||
| assert.Equal(t, uint64(3), contributor.MulticastPublishersCount) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -110,7 +110,11 @@ func (a *API) GetDevices(w http.ResponseWriter, r *http.Request) { | |
| GROUP BY device_pk | ||
| ), | ||
| multicast_counts AS ( | ||
| SELECT device_pk, count(*) as user_count | ||
| SELECT | ||
| device_pk, | ||
| count(*) as user_count, | ||
| countIf(JSONLength(subscribers) > 0) as subscriber_count, | ||
| countIf(JSONLength(publishers) > 0) as publisher_count | ||
| FROM dz_users_current | ||
| WHERE status = 'activated' AND kind = 'multicast' | ||
| GROUP BY device_pk | ||
|
|
@@ -158,9 +162,12 @@ func (a *API) GetDevices(w http.ResponseWriter, r *http.Request) { | |
| COALESCE(d.max_multicast_subscribers, 0) as max_multicast_subscribers, | ||
| COALESCE(d.max_multicast_publishers, 0) as max_multicast_publishers, | ||
| COALESCE(d.unicast_users_count, 0) as unicast_users_count, | ||
| COALESCE(d.multicast_subscribers_count, 0) as multicast_subscribers_count, | ||
| -- Live subscriber/publisher counts from attached users. The on-chain | ||
| -- d.multicast_subscribers_count / d.multicast_publishers_count fields are | ||
| -- frequently stale or 0 even when users are attached (#650). | ||
| toUInt16(COALESCE(ucm.subscriber_count, 0)) as multicast_subscribers_count, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| COALESCE(d.reserved_seats, 0) as reserved_seats, | ||
| COALESCE(d.multicast_publishers_count, 0) as multicast_publishers_count, | ||
| toUInt16(COALESCE(ucm.publisher_count, 0)) as multicast_publishers_count, | ||
| COALESCE(tr.in_bps, 0) as in_bps, | ||
| COALESCE(tr.out_bps, 0) as out_bps, | ||
| COALESCE(pr.peak_in_bps, 0) as peak_in_bps, | ||
|
|
@@ -360,7 +367,11 @@ func (a *API) GetDevice(w http.ResponseWriter, r *http.Request) { | |
| GROUP BY device_pk | ||
| ), | ||
| multicast_counts AS ( | ||
| SELECT device_pk, count(*) as user_count | ||
| SELECT | ||
| device_pk, | ||
| count(*) as user_count, | ||
| countIf(JSONLength(subscribers) > 0) as subscriber_count, | ||
| countIf(JSONLength(publishers) > 0) as publisher_count | ||
| FROM dz_users_current | ||
| WHERE status = 'activated' AND kind = 'multicast' AND device_pk = ? | ||
| GROUP BY device_pk | ||
|
|
@@ -408,9 +419,11 @@ func (a *API) GetDevice(w http.ResponseWriter, r *http.Request) { | |
| COALESCE(d.max_multicast_subscribers, 0) as max_multicast_subscribers, | ||
| COALESCE(d.max_multicast_publishers, 0) as max_multicast_publishers, | ||
| COALESCE(d.unicast_users_count, 0) as unicast_users_count, | ||
| COALESCE(d.multicast_subscribers_count, 0) as multicast_subscribers_count, | ||
| -- Live subscriber/publisher counts from attached users; on-chain | ||
| -- counts are frequently stale or 0 even with users attached (#650). | ||
| toUInt16(COALESCE(ucm.subscriber_count, 0)) as multicast_subscribers_count, | ||
| COALESCE(d.reserved_seats, 0) as reserved_seats, | ||
| COALESCE(d.multicast_publishers_count, 0) as multicast_publishers_count, | ||
| toUInt16(COALESCE(ucm.publisher_count, 0)) as multicast_publishers_count, | ||
| COALESCE(tr.in_bps, 0) as in_bps, | ||
| COALESCE(tr.out_bps, 0) as out_bps, | ||
| COALESCE(pr.peak_in_bps, 0) as peak_in_bps, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -383,3 +383,85 @@ func TestGetDevice_HandlesNullContributor(t *testing.T) { | |
| assert.Equal(t, "", device.ContributorPK) | ||
| assert.Equal(t, "", device.ContributorCode) | ||
| } | ||
|
|
||
| // insertMulticastCountTestData inserts a device whose on-chain multicast | ||
| // subscriber/publisher counts are left at their default of 0 (the stale state | ||
| // seen in production), plus activated multicast users that actually subscribe | ||
| // to and/or publish to groups. The handler must report the live counts derived | ||
| // from these users, not the stale on-chain zeros (#650). | ||
| func insertMulticastCountTestData(t *testing.T, api *handlers.API) { | ||
| ctx := t.Context() | ||
|
|
||
| err := api.DB.Exec(ctx, ` | ||
| INSERT INTO dim_dz_devices_history | ||
| (entity_id, snapshot_ts, ingested_at, op_id, is_deleted, attrs_hash, pk, code, status, device_type, contributor_pk, metro_pk, public_ip, max_users) VALUES | ||
| ('dev-mc', now(), now(), generateUUIDv4(), 0, 1, 'dev-mc', 'MC-DEVICE-01', 'activated', 'switch', '', '', '10.0.9.1', 100) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixture (and the contributor/facility/metro helpers) leaves the on-chain |
||
| `) | ||
| require.NoError(t, err) | ||
|
|
||
| // 3 subscriber-only, 2 publisher-only, 1 both-roles, 1 pending (must be excluded). | ||
| err = api.DB.Exec(ctx, ` | ||
| INSERT INTO dim_dz_users_history | ||
| (entity_id, snapshot_ts, ingested_at, op_id, is_deleted, attrs_hash, pk, status, device_pk, kind, owner_pubkey, client_ip, dz_ip, tunnel_id, publishers, subscribers) VALUES | ||
| ('mc-s1', now(), now(), generateUUIDv4(), 0, 1, 'mc-s1', 'activated', 'dev-mc', 'multicast', 'p', '', '', 0, '[]', '["g1"]'), | ||
| ('mc-s2', now(), now(), generateUUIDv4(), 0, 2, 'mc-s2', 'activated', 'dev-mc', 'multicast', 'p', '', '', 0, '[]', '["g1"]'), | ||
| ('mc-s3', now(), now(), generateUUIDv4(), 0, 3, 'mc-s3', 'activated', 'dev-mc', 'multicast', 'p', '', '', 0, '[]', '["g2"]'), | ||
| ('mc-p1', now(), now(), generateUUIDv4(), 0, 4, 'mc-p1', 'activated', 'dev-mc', 'multicast', 'p', '', '', 0, '["g1"]', '[]'), | ||
| ('mc-p2', now(), now(), generateUUIDv4(), 0, 5, 'mc-p2', 'activated', 'dev-mc', 'multicast', 'p', '', '', 0, '["g1"]', '[]'), | ||
| ('mc-b1', now(), now(), generateUUIDv4(), 0, 6, 'mc-b1', 'activated', 'dev-mc', 'multicast', 'p', '', '', 0, '["g2"]', '["g2"]'), | ||
| ('mc-pend', now(), now(), generateUUIDv4(), 0, 7, 'mc-pend', 'pending', 'dev-mc', 'multicast', 'p', '', '', 0, '[]', '["g1"]') | ||
| `) | ||
| require.NoError(t, err) | ||
| } | ||
|
|
||
| func TestGetDevices_MulticastCountsFromLiveUsers(t *testing.T) { | ||
| t.Parallel() | ||
| api := apitesting.NewTestAPI(t, testChDB) | ||
|
|
||
| insertMulticastCountTestData(t, api) | ||
|
|
||
| req := httptest.NewRequest(http.MethodGet, "/api/dz/devices", nil) | ||
| rr := httptest.NewRecorder() | ||
| api.GetDevices(rr, req) | ||
| require.Equal(t, http.StatusOK, rr.Code) | ||
|
|
||
| var response handlers.PaginatedResponse[handlers.DeviceListItem] | ||
| require.NoError(t, json.NewDecoder(rr.Body).Decode(&response)) | ||
|
|
||
| var dev *handlers.DeviceListItem | ||
| for i := range response.Items { | ||
| if response.Items[i].Code == "MC-DEVICE-01" { | ||
| dev = &response.Items[i] | ||
| break | ||
| } | ||
| } | ||
| require.NotNil(t, dev) | ||
| // On-chain counts are 0, but live users give: 6 multicast users, | ||
| // 4 subscribers (mc-s1, mc-s2, mc-s3, mc-b1), 3 publishers (mc-p1, mc-p2, mc-b1). | ||
| assert.Equal(t, uint64(6), dev.MulticastUsers) | ||
| assert.Equal(t, uint16(4), dev.MulticastSubscribersCount) | ||
| assert.Equal(t, uint16(3), dev.MulticastPublishersCount) | ||
| } | ||
|
|
||
| func TestGetDevice_MulticastCountsFromLiveUsers(t *testing.T) { | ||
| t.Parallel() | ||
| api := apitesting.NewTestAPI(t, testChDB) | ||
|
|
||
| insertMulticastCountTestData(t, api) | ||
|
|
||
| req := httptest.NewRequest(http.MethodGet, "/api/dz/devices/dev-mc", nil) | ||
| rctx := chi.NewRouteContext() | ||
| rctx.URLParams.Add("pk", "dev-mc") | ||
| req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) | ||
|
|
||
| rr := httptest.NewRecorder() | ||
| api.GetDevice(rr, req) | ||
| require.Equal(t, http.StatusOK, rr.Code) | ||
|
|
||
| var device handlers.DeviceDetail | ||
| require.NoError(t, json.NewDecoder(rr.Body).Decode(&device)) | ||
|
|
||
| assert.Equal(t, uint64(6), device.MulticastUsers) | ||
| assert.Equal(t, uint16(4), device.MulticastSubscribersCount) | ||
| assert.Equal(t, uint16(3), device.MulticastPublishersCount) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| package handlers_test | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "github.com/malbeclabs/lake/api/handlers" | ||
| apitesting "github.com/malbeclabs/lake/api/testing" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // insertFacilityMulticastData inserts a facility with one device (via location_pk) | ||
| // whose on-chain multicast counts are left at 0, plus activated multicast users | ||
| // that subscribe to / publish to groups. The facility aggregate must report the | ||
| // live per-device counts, not the stale on-chain zeros (#650). | ||
| func insertFacilityMulticastData(t *testing.T, api *handlers.API) { | ||
| ctx := t.Context() | ||
|
|
||
| err := api.DB.Exec(ctx, ` | ||
| INSERT INTO dim_dz_facilities_history | ||
| (entity_id, snapshot_ts, ingested_at, op_id, is_deleted, attrs_hash, pk, owner, lat, lng, loc_id, status, code, name, country, reference_count) VALUES | ||
| ('fac-mc', now(), now(), generateUUIDv4(), 0, 1, 'fac-mc', '', 40.0, -74.0, 1, 'activated', 'FAC-MC', 'Multicast Facility', 'US', 1) | ||
| `) | ||
| require.NoError(t, err) | ||
|
|
||
| err = api.DB.Exec(ctx, ` | ||
| INSERT INTO dim_dz_devices_history | ||
| (entity_id, snapshot_ts, ingested_at, op_id, is_deleted, attrs_hash, pk, code, status, device_type, contributor_pk, metro_pk, location_pk, public_ip, max_users) VALUES | ||
| ('fdev-1', now(), now(), generateUUIDv4(), 0, 1, 'fdev-1', 'F-DEV-01', 'up', 'switch', '', '', 'fac-mc', '10.7.0.1', 100) | ||
| `) | ||
| require.NoError(t, err) | ||
|
|
||
| // 3 subscriber-only, 2 publisher-only, 1 both-roles, 1 pending (excluded). | ||
| // Live totals: subscribers 4, publishers 3. | ||
| err = api.DB.Exec(ctx, ` | ||
| INSERT INTO dim_dz_users_history | ||
| (entity_id, snapshot_ts, ingested_at, op_id, is_deleted, attrs_hash, pk, status, device_pk, kind, owner_pubkey, client_ip, dz_ip, tunnel_id, publishers, subscribers) VALUES | ||
| ('f-s1', now(), now(), generateUUIDv4(), 0, 1, 'f-s1', 'activated', 'fdev-1', 'multicast', 'p', '', '', 0, '[]', '["g1"]'), | ||
| ('f-s2', now(), now(), generateUUIDv4(), 0, 2, 'f-s2', 'activated', 'fdev-1', 'multicast', 'p', '', '', 0, '[]', '["g1"]'), | ||
| ('f-s3', now(), now(), generateUUIDv4(), 0, 3, 'f-s3', 'activated', 'fdev-1', 'multicast', 'p', '', '', 0, '[]', '["g2"]'), | ||
| ('f-p1', now(), now(), generateUUIDv4(), 0, 4, 'f-p1', 'activated', 'fdev-1', 'multicast', 'p', '', '', 0, '["g1"]', '[]'), | ||
| ('f-p2', now(), now(), generateUUIDv4(), 0, 5, 'f-p2', 'activated', 'fdev-1', 'multicast', 'p', '', '', 0, '["g1"]', '[]'), | ||
| ('f-b1', now(), now(), generateUUIDv4(), 0, 6, 'f-b1', 'activated', 'fdev-1', 'multicast', 'p', '', '', 0, '["g2"]', '["g2"]'), | ||
| ('f-pend', now(), now(), generateUUIDv4(), 0, 7, 'f-pend', 'pending', 'fdev-1', 'multicast', 'p', '', '', 0, '[]', '["g1"]') | ||
| `) | ||
| require.NoError(t, err) | ||
| } | ||
|
|
||
| func TestGetFacilities_MulticastCountsFromLiveUsers(t *testing.T) { | ||
| t.Parallel() | ||
| api := apitesting.NewTestAPI(t, testChDB) | ||
|
|
||
| insertFacilityMulticastData(t, api) | ||
|
|
||
| req := httptest.NewRequest(http.MethodGet, "/api/dz/facilities", nil) | ||
| rr := httptest.NewRecorder() | ||
| api.GetFacilities(rr, req) | ||
| require.Equal(t, http.StatusOK, rr.Code) | ||
|
|
||
| var response handlers.PaginatedResponse[handlers.FacilityListItem] | ||
| require.NoError(t, json.NewDecoder(rr.Body).Decode(&response)) | ||
|
|
||
| var f *handlers.FacilityListItem | ||
| for i := range response.Items { | ||
| if response.Items[i].PK == "fac-mc" { | ||
| f = &response.Items[i] | ||
| break | ||
| } | ||
| } | ||
| require.NotNil(t, f) | ||
| assert.Equal(t, uint64(4), f.MulticastSubscribersCount) | ||
| assert.Equal(t, uint64(3), f.MulticastPublishersCount) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
eff_max_subs/eff_max_pubs(lines 323-324) are not floored at the live count, unlike metros (metros.go:121-122,416-417wrap the same expression ingreatest(..., toUInt64(COALESCE(dml.live_subscribers, 0)))) and devices (devices.go:187-188). With a now-live used-count and an on-chain-derived cap, a contributor whose devices havemax_multicast_subscribers = 0and exhaustedmax_userscan returnmulticast_subscribers_count > max_multicast_subscribersin the JSON. The web clamps viaMath.max(contributor-detail-page.tsx:232), but the agent/MCP see an oversubscribed contributor. Wrap both ingreatest(if(...), toUInt64(COALESCE(dml.live_subscribers, 0)))/live_publishers.