carlos / platform Public

edge: the browser door's token buckets, per app, per door, per visitor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev ccb12c3157d0f72d84bb33cf606e0d6ea83df660 parent 987ffd1
2 files changed, +168 −0
  • internal/edge/doorlimit.go +111 −0
  • internal/edge/doorlimit_test.go +57 −0
diff --git a/internal/edge/doorlimit.go b/internal/edge/doorlimit.go
new file mode 100644
index 000000000..8fe0a1621
--- /dev/null
+++ b/internal/edge/doorlimit.go
@@ -0,0 +1,111 @@
+package edge
+
+import (
+ "container/list"
+ "sync"
+ "time"
+)
+
+// The browser door's rate (spec §3.4): token buckets per app for each
+// door and per visitor, all in edge memory, all dropping silently and
+// counting dropped.rate. Visitor state is bounded per app with LRU
+// eviction so a scan of a million addresses costs a fixed table.
+const (
+ doorAppPerMinute = 600
+ doorVisitorPerMinute = 60
+ doorVisitorEntries = 10000
+)
+
+type tokenBucket struct {
+ tokens float64
+ cap float64
+ last time.Time
+}
+
+func (b *tokenBucket) take(now time.Time) bool {
+ if b.last.IsZero() {
+ b.tokens, b.last = b.cap, now
+ }
+ if el := now.Sub(b.last); el > 0 {
+ b.tokens += el.Seconds() * b.cap / 60
+ if b.tokens > b.cap {
+ b.tokens = b.cap
+ }
+ b.last = now
+ }
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+func (b *tokenBucket) has(now time.Time) bool { // peek: refill, but spend nothing
+ if b.last.IsZero() {
+ b.tokens, b.last = b.cap, now
+ }
+ if el := now.Sub(b.last); el > 0 {
+ b.tokens += el.Seconds() * b.cap / 60
+ if b.tokens > b.cap {
+ b.tokens = b.cap
+ }
+ b.last = now
+ }
+ return b.tokens >= 1
+}
+
+type visitorEntry struct {
+ key uint64
+ b tokenBucket
+}
+
+type doorAppState struct {
+ bucket map[byte]*tokenBucket // 'e', 'a'
+ visitors *list.List // front = most recent; *visitorEntry values
+ visitorIdx map[uint64]*list.Element
+}
+
+type doorLimiter struct {
+ mu sync.Mutex
+ apps map[string]*doorAppState
+}
+
+func newDoorLimiter() *doorLimiter { return &doorLimiter{apps: map[string]*doorAppState{}} }
+
+// allow spends one token from the app's door bucket and one from the
+// visitor's bucket; when either is empty nothing is spent and the post is
+// refused. Both are checked before either is charged.
+func (l *doorLimiter) allow(app string, door byte, visitor uint64, now time.Time) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ st := l.apps[app]
+ if st == nil {
+ st = &doorAppState{
+ bucket: map[byte]*tokenBucket{'e': {cap: doorAppPerMinute}, 'a': {cap: doorAppPerMinute}},
+ visitors: list.New(),
+ visitorIdx: map[uint64]*list.Element{},
+ }
+ l.apps[app] = st
+ }
+ ab := st.bucket[door]
+ if ab == nil {
+ return false
+ }
+ el := st.visitorIdx[visitor]
+ if el == nil {
+ if st.visitors.Len() >= doorVisitorEntries {
+ old := st.visitors.Back()
+ st.visitors.Remove(old)
+ delete(st.visitorIdx, old.Value.(*visitorEntry).key)
+ }
+ el = st.visitors.PushFront(&visitorEntry{key: visitor, b: tokenBucket{cap: doorVisitorPerMinute}})
+ st.visitorIdx[visitor] = el
+ } else {
+ st.visitors.MoveToFront(el)
+ }
+ vb := &el.Value.(*visitorEntry).b
+ if !ab.has(now) || !vb.has(now) {
+ return false
+ }
+ return ab.take(now) && vb.take(now)
+}
diff --git a/internal/edge/doorlimit_test.go b/internal/edge/doorlimit_test.go
new file mode 100644
index 000000000..782f6e492
--- /dev/null
+++ b/internal/edge/doorlimit_test.go
@@ -0,0 +1,57 @@
+package edge
+
+import (
+ "testing"
+ "time"
+)
+
+func TestDoorLimiterAppBucket(t *testing.T) {
+ l := newDoorLimiter()
+ now := time.Unix(1_700_000_000, 0)
+ for i := 0; i < doorAppPerMinute; i++ {
+ if !l.allow("a/web", 'e', uint64(i), now) { // a fresh visitor each time: only the app bucket can refuse
+ t.Fatalf("post %d refused", i+1)
+ }
+ }
+ if l.allow("a/web", 'e', 99999, now) {
+ t.Fatal("the 601st error in a minute must be refused")
+ }
+ if !l.allow("a/web", 'a', 99999, now) {
+ t.Fatal("the events bucket is separate")
+ }
+ if !l.allow("a/other", 'e', 99999, now) {
+ t.Fatal("another app is unaffected")
+ }
+ if !l.allow("a/web", 'e', 99999, now.Add(6*time.Second)) {
+ t.Fatal("six seconds refill 60 tokens")
+ }
+}
+
+func TestDoorLimiterVisitorBucketAndLRU(t *testing.T) {
+ l := newDoorLimiter()
+ now := time.Unix(1_700_000_000, 0)
+ for i := 0; i < doorVisitorPerMinute; i++ {
+ if !l.allow("a/web", 'a', 7, now) {
+ t.Fatalf("visitor post %d refused", i+1)
+ }
+ }
+ if l.allow("a/web", 'a', 7, now) {
+ t.Fatal("the 61st post from one visitor must be refused")
+ }
+ if !l.allow("a/web", 'a', 8, now) {
+ t.Fatal("another visitor is unaffected")
+ }
+ // Refusal spends nothing: the app bucket still has its tokens.
+ if got := l.apps["a/web"].bucket['a'].tokens; got < float64(doorAppPerMinute-61) {
+ t.Fatalf("app tokens %v, refused visitor posts must not spend them", got)
+ }
+ for i := uint64(100); i < 100+doorVisitorEntries; i++ {
+ l.allow("a/web", 'a', i, now)
+ }
+ if n := l.apps["a/web"].visitors.Len(); n != doorVisitorEntries {
+ t.Fatalf("visitor table %d entries, want the cap", n)
+ }
+ if _, ok := l.apps["a/web"].visitorIdx[7]; ok {
+ t.Fatal("the oldest visitor must have been evicted")
+ }
+}