carlos / platform Public

usage, edge: a host's new owner starts its refusal counts afresh (#358)

Both accumulation stages, the edge's UsageMeter and EdgeDay.AddDeltas,
relabel a host's row to its latest account and app without separating
its counters. After a same-hour ownership change the console's
rate-limit GET, which filters rows by account and app, showed the new
owner the previous owner's RateShed and RateCeiling.

usage.EdgeRow.SetOwner relabels a row and, when the owner changes,
resets the two refusal counters first. Requests, BytesOut and the hop
subset carry on exactly as before, and the wire format is unchanged.
The rateLimitCounts doc comment now states the reset instead of the
accepted leak.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7af07df49ba0531e50b1d7fb2f8a5b9bf1b0a7c9 parent 53ad87c
6 files changed, +96 −11
  • internal/console/apiinstances_ratelimit.go +4 −5
  • internal/console/apiinstances_ratelimit_test.go +20 −0
  • internal/edge/usagemeter.go +2 −4
  • internal/edge/usagemeter_test.go +21 −0
  • internal/usage/edge.go +18 −2
  • internal/usage/edge_ratelimit_test.go +31 −0
diff --git a/internal/console/apiinstances_ratelimit.go b/internal/console/apiinstances_ratelimit.go
index 0a928b1a1..e010eef46 100644
--- a/internal/console/apiinstances_ratelimit.go
+++ b/internal/console/apiinstances_ratelimit.go
@@ -174,11 +174,10 @@ func (s *Server) handleAPIInstanceRateLimitGet(w http.ResponseWriter, r *http.Re
//
// A row counts only under this instance's own account and app. A host can
// change hands, and the last owner's traffic is not the new owner's to see.
-// The filter is only as fine as the row: usage.EdgeDay.AddDeltas relabels a
-// host's row to its latest owner within an hour, so a host re-owned inside
-// one hour shows the previous owner's counts for that hour. That is
-// accepted: the leak is two aggregate counts with no client data, and a
-// host changing hands is rare. Fixing it would change the usage format.
+// A row is labelled with its latest owner, and both accumulation stages (the
+// edge's UsageMeter and usage.EdgeDay.AddDeltas) reset its two refusal
+// counters when that label changes (usage.EdgeRow.SetOwner), so a host
+// re-owned inside one hour shows the new owner only its own refusals.
//
// A missing day object is a box that served nothing that day. One that
// does not decode is an error, never a zero.
diff --git a/internal/console/apiinstances_ratelimit_test.go b/internal/console/apiinstances_ratelimit_test.go
index 02723d54e..c86222446 100644
--- a/internal/console/apiinstances_ratelimit_test.go
+++ b/internal/console/apiinstances_ratelimit_test.go
@@ -321,6 +321,26 @@ func TestRateLimitCountsWindow(t *testing.T) {
}
}
+// A host re-owned within an hour: the GET shows the new owner only its own
+// refusals, never the previous owner's, from the same box's same hour row.
+func TestRateLimitGetSameHourOwnerChange(t *testing.T) {
+ fx, token := rateLimitFixture(t, "code-rl-owner")
+ now := time.Now()
+ rlUpsert(t, fx, "box-a", now, rlHost, usage.EdgeRow{Account: "bac", App: "shop", Requests: 90, RateShed: 50, RateCeiling: 40})
+ rlUpsert(t, fx, "box-a", now, rlHost, usage.EdgeRow{Account: "bab", App: "shop", Requests: 3, RateShed: 2})
+ status, _, out := rawReq(t, http.MethodGet, updateURL(fx, "bab", "shop", rlHost)+"/rate-limit", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("GET = %d %s", status, out)
+ }
+ var v rlRateLimitView
+ if err := json.Unmarshal([]byte(out), &v); err != nil {
+ t.Fatal(err)
+ }
+ if v.Counts.Shed != 2 || v.Counts.Ceiling != 0 {
+ t.Fatalf("counts = %+v, want shed 2 ceiling 0: the previous owner's refusals are not this owner's", v.Counts)
+ }
+}
+
// A box's day object that does not decode fails the GET with its usage 502
// rather than answering a short total that reads as fewer refusals.
func TestRateLimitGetMalformedDay502(t *testing.T) {
diff --git a/internal/edge/usagemeter.go b/internal/edge/usagemeter.go
index d5e8a1a27..41cf2b038 100644
--- a/internal/edge/usagemeter.go
+++ b/internal/edge/usagemeter.go
@@ -37,8 +37,7 @@ func NewUsageMeter() *UsageMeter {
func (u *UsageMeter) Record(rt registry.Route, requests, bytes int64, hopped bool) {
u.mu.Lock()
row := u.m[rt.Host]
- row.Account = release.RouteAccount(rt)
- row.App = rt.App
+ row.SetOwner(release.RouteAccount(rt), rt.App)
row.Requests += requests
row.BytesOut += bytes
if hopped {
@@ -59,8 +58,7 @@ func (u *UsageMeter) RecordRateLimited(rt registry.Route, d ratelimit.Decision)
}
u.mu.Lock()
row := u.m[rt.Host]
- row.Account = release.RouteAccount(rt)
- row.App = rt.App
+ row.SetOwner(release.RouteAccount(rt), rt.App)
if d == ratelimit.Shed {
row.RateShed++
} else {
diff --git a/internal/edge/usagemeter_test.go b/internal/edge/usagemeter_test.go
index 15dbdb69b..5c3f78a99 100644
--- a/internal/edge/usagemeter_test.go
+++ b/internal/edge/usagemeter_test.go
@@ -6,8 +6,10 @@ import (
"sync"
"testing"
+ "github.com/carlosframework/platform/internal/ratelimit"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/usage"
)
func TestUsageMeterRecordAndDrain(t *testing.T) {
@@ -116,3 +118,22 @@ func TestCountingWriterObservesStatusAndType(t *testing.T) {
t.Fatal("untouched writer must report 0")
}
}
+
+// A route that changes hands between flushes starts its refusal counts
+// afresh under the new owner; requests and bytes fold as before.
+func TestUsageMeterOwnerChangeResetsRateCounters(t *testing.T) {
+ m := NewUsageMeter()
+ before := registry.Route{Host: "shop.example.com", App: "shop", Account: "bac"}
+ after := registry.Route{Host: "shop.example.com", App: "shop", Account: "bab"}
+ m.RecordRateLimited(before, ratelimit.Shed)
+ m.RecordRateLimited(before, ratelimit.OverCeiling)
+ m.Record(before, 2, 20, false)
+ m.RecordRateLimited(after, ratelimit.Shed)
+ m.Record(after, 1, 10, false)
+
+ got := m.Drain()["shop.example.com"]
+ want := usage.EdgeRow{Account: "bab", App: "shop", Requests: 3, BytesOut: 30, RateShed: 1}
+ if got != want {
+ t.Fatalf("row = %+v, want %+v", got, want)
+ }
+}
diff --git a/internal/usage/edge.go b/internal/usage/edge.go
index 484933bd6..68ab20eb8 100644
--- a/internal/usage/edge.go
+++ b/internal/usage/edge.go
@@ -42,6 +42,20 @@ type EdgeRow struct {
RateCeiling int64 `json:"rate_ceiling,omitempty"`
}
+// SetOwner labels the row with the route's current account and app. When
+// that changes who owns a row, the rate-limit refusal counters start again
+// from zero first: the owner reads them back (the console's rate-limit GET
+// filters rows by account and app), and the previous owner's refusals are
+// not the new owner's to see. Requests, BytesOut and the hop subset carry on
+// summing under the new label as they always have (modelling grade, see
+// AddDeltas).
+func (r *EdgeRow) SetOwner(account, app string) {
+ if r.Account != account || r.App != app {
+ r.RateShed, r.RateCeiling = 0, 0
+ }
+ r.Account, r.App = account, app
+}
+
// EdgeDay is usage/edge/<box>/days/<date>.json — a box's edge traffic for
// one UTC day, hour-bucketed like metrics.DayRollup. Unlike metrics there is
// no separate raw-chunk family: the 5-minute flush CAS-merges deltas
@@ -67,7 +81,9 @@ const EdgeBoxesPrefix = "usage/edge/"
// AddDeltas folds drained per-host deltas into the day's hour bucket,
// summing counters and refreshing the route identity (a host re-pointed to
-// a new app mid-hour lands on the later identity — modelling grade).
+// a new app mid-hour lands on the later identity — modelling grade). The
+// rate-limit counters are the exception: a new identity starts them afresh
+// (SetOwner).
func (d *EdgeDay) AddDeltas(hour string, deltas map[string]EdgeRow) {
if d.Hours == nil {
d.Hours = map[string]map[string]EdgeRow{}
@@ -79,7 +95,7 @@ func (d *EdgeDay) AddDeltas(hour string, deltas map[string]EdgeRow) {
}
for host, delta := range deltas {
row := rows[host]
- row.Account, row.App = delta.Account, delta.App
+ row.SetOwner(delta.Account, delta.App)
row.Requests += delta.Requests
row.BytesOut += delta.BytesOut
row.HopRequests += delta.HopRequests
diff --git a/internal/usage/edge_ratelimit_test.go b/internal/usage/edge_ratelimit_test.go
index 0e1a16995..a262aeb89 100644
--- a/internal/usage/edge_ratelimit_test.go
+++ b/internal/usage/edge_ratelimit_test.go
@@ -33,3 +33,34 @@ func TestAddDeltasSumsRateCounters(t *testing.T) {
t.Fatalf("json = %s, want no rate fields on a row that has none", b)
}
}
+
+// A host that changes hands within an hour starts its refusal counts afresh
+// under the new owner: they are readable by the row's owner, and the last
+// owner's are not the new one's to see. Requests, bytes and the hop subset
+// fold exactly as before, whoever owns the row.
+func TestAddDeltasOwnerChangeResetsRateCounters(t *testing.T) {
+ var d EdgeDay
+ d.AddDeltas("09", map[string]EdgeRow{"shop.example.com": {
+ Account: "bac", App: "shop", Requests: 10, BytesOut: 100, HopRequests: 4, HopBytesOut: 40, RateShed: 3, RateCeiling: 2,
+ }})
+ d.AddDeltas("09", map[string]EdgeRow{"shop.example.com": {
+ Account: "bab", App: "shop", Requests: 1, BytesOut: 5, HopRequests: 1, HopBytesOut: 5, RateShed: 1,
+ }})
+ want := EdgeRow{Account: "bab", App: "shop", Requests: 11, BytesOut: 105, HopRequests: 5, HopBytesOut: 45, RateShed: 1}
+ if got := d.Hours["09"]["shop.example.com"]; got != want {
+ t.Fatalf("after an account change = %+v, want %+v", got, want)
+ }
+
+ // Another app under the same account is another owner too.
+ d.AddDeltas("09", map[string]EdgeRow{"shop.example.com": {Account: "bab", App: "other", Requests: 1, RateCeiling: 7}})
+ want = EdgeRow{Account: "bab", App: "other", Requests: 12, BytesOut: 105, HopRequests: 5, HopBytesOut: 45, RateCeiling: 7}
+ if got := d.Hours["09"]["shop.example.com"]; got != want {
+ t.Fatalf("after an app change = %+v, want %+v", got, want)
+ }
+
+ // The same owner keeps summing.
+ d.AddDeltas("09", map[string]EdgeRow{"shop.example.com": {Account: "bab", App: "other", RateShed: 2, RateCeiling: 1}})
+ if got := d.Hours["09"]["shop.example.com"]; got.RateShed != 2 || got.RateCeiling != 8 {
+ t.Fatalf("same owner = %+v, want rate_shed 2, rate_ceiling 8", got)
+ }
+}