carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package relay: Meter accumulates per-app byte and packet counts and
2// flushes them as an hourly time series to the deployment bucket (spec §3
3// metering).
4package relay
5
6import (
7 "context"
8 "encoding/json"
9 "errors"
10 "os"
11 "strconv"
12 "sync"
13 "time"
14
15 "github.com/carlosframework/platform/internal/metrics"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// Meter satisfies ServerConfig.Meter (internal/relay/server.go).
20var _ interface {
21 Add(id Identity, in, out, rejected int64)
22} = (*Meter)(nil)
23
24// MeterKey is the bucket key for a box's metering chunk covering the given
25// hour (metrics.HourOf's format, e.g. 2026-09-07-12).
26func MeterKey(box, hour string) string { return "metrics/relay/" + box + "/" + hour + ".json" }
27
28// MeterApp is one app's counters for one sample interval. In and Out are
29// bytes forwarded on the two relay legs, counted at the socket writes — Out
30// includes the STUN Data-indication framing (about 36 bytes per packet), so
31// it is larger than the raw media payload. Rejected is every peer packet
32// that arrived at a relay socket and was not forwarded (no permission, over
33// a limit, etc). Allocs is a gauge: the number of open allocations at
34// sample time, not a delta.
35type MeterApp struct {
36 In int64 `json:"in"`
37 Out int64 `json:"out"`
38 Rejected int64 `json:"rejected"`
39 Allocs int `json:"allocs"`
40}
41
42// MeterSample is one flush's worth of per-app counters, keyed
43// "<account>/<app>".
44type MeterSample struct {
45 At time.Time `json:"at"`
46 Apps map[string]MeterApp `json:"apps"`
47}
48
49// MeterChunk is the hourly object at MeterKey: every sample flushed for
50// that box and hour, in order.
51type MeterChunk struct {
52 V int `json:"v"`
53 Box string `json:"box"`
54 Hour string `json:"hour"`
55 Samples []MeterSample `json:"samples"`
56}
57
58// Meter accumulates per-app counters between flushes and appends them as a
59// sample to the box's hourly object. Safe for concurrent use.
60type Meter struct {
61 mu sync.Mutex
62 box string
63 now func() time.Time
64 // Log receives diagnostics (a corrupt chunk found, etc); nil discards.
65 Log func(string, ...any)
66 cur map[Identity]*MeterApp
67}
68
69// NewMeter returns a Meter for box, using now for both sample timestamps
70// and hour selection (a func so tests control it), and logf for
71// diagnostics (nil discards them).
72func NewMeter(box string, now func() time.Time, logf func(string, ...any)) *Meter {
73 return &Meter{box: box, now: now, Log: logf, cur: map[Identity]*MeterApp{}}
74}
75
76func (m *Meter) log(format string, args ...any) {
77 if m.Log != nil {
78 m.Log(format, args...)
79 }
80}
81
82func (m *Meter) get(id Identity) *MeterApp {
83 a, ok := m.cur[id]
84 if !ok {
85 a = &MeterApp{}
86 m.cur[id] = a
87 }
88 return a
89}
90
91// Add accumulates in/out bytes and rejected packets for id since the last
92// flush.
93func (m *Meter) Add(id Identity, in, out, rejected int64) {
94 m.mu.Lock()
95 a := m.get(id)
96 a.In += in
97 a.Out += out
98 a.Rejected += rejected
99 m.mu.Unlock()
100}
101
102// SetAllocs sets id's current open-allocation gauge.
103func (m *Meter) SetAllocs(id Identity, n int) {
104 m.mu.Lock()
105 m.get(id).Allocs = n
106 m.mu.Unlock()
107}
108
109// SetAllocsAll sets the gauge from a complete census of live allocations:
110// every id in live gets its count, and every id the meter is carrying that
111// live does NOT name gets zero.
112//
113// The zeroing half is the point. Flush keeps an app whose gauge is above
114// zero and evicts one whose four counters are all zero, so an app that is
115// only ever set and never cleared reports a phantom allocation in every
116// sample from then on and is never evicted — a slow leak in a process the
117// unit caps at 256 MiB, and a lie in the numbers the bill is made of.
118// Setting the gauge one id at a time cannot do this, because the ids that
119// need zeroing are precisely the ones the caller no longer sees.
120func (m *Meter) SetAllocsAll(live map[Identity]int) {
121 m.mu.Lock()
122 defer m.mu.Unlock()
123 for id, a := range m.cur {
124 a.Allocs = live[id] // absent from live: zero
125 }
126 for id, n := range live {
127 m.get(id).Allocs = n
128 }
129}
130
131// Flush writes one sample of deltas since the previous flush, appended to
132// this box's hourly object by read-append-CAS. The relay is the only
133// writer of these objects, so the CAS only ever races with itself.
134//
135// An app with nothing to report — every delta zero and no open
136// allocations — is dropped from the sample and from the meter's live set,
137// so a burst of short-lived apps does not grow m.cur without bound. An app
138// with open allocations is kept (with zero deltas) even while idle, so its
139// allocation gauge keeps being reported. And a sample left naming no app at
140// all is not written: an idle relay writes nothing rather than a minute-by-
141// minute record of having nothing to say.
142func (m *Meter) Flush(ctx context.Context, s release.Store) error {
143 m.mu.Lock()
144 now := m.now()
145 sample := MeterSample{At: now, Apps: map[string]MeterApp{}}
146 for id, a := range m.cur {
147 if a.In == 0 && a.Out == 0 && a.Rejected == 0 && a.Allocs == 0 {
148 delete(m.cur, id)
149 continue
150 }
151 sample.Apps[id.Account+"/"+id.App] = *a
152 m.cur[id] = &MeterApp{Allocs: a.Allocs} // deltas reset, gauge kept
153 }
154 m.mu.Unlock()
155 // Nothing to say, so say nothing. Almost every relay box is idle almost
156 // all of the time, and a sample with no apps in it is a read, a write and
157 // a row of JSON that carries no information — 1,440 of them a day per
158 // box, growing the hourly object a reader has to walk and the CAS chain
159 // that guards it. An hour in which nothing happened is best represented
160 // by an hour with no object.
161 if len(sample.Apps) == 0 {
162 return nil
163 }
164 hour := metrics.HourOf(now)
165 key := MeterKey(m.box, hour)
166 var lastErr error
167 for attempt := 0; attempt < 3; attempt++ {
168 body, etag, err := s.Get(ctx, key)
169 var c MeterChunk
170 switch {
171 case errors.Is(err, os.ErrNotExist):
172 c = MeterChunk{V: 1, Box: m.box, Hour: hour}
173 etag = ""
174 case err != nil:
175 return err
176 default:
177 if uerr := json.Unmarshal(body, &c); uerr != nil {
178 m.log("relay: meter chunk %s unreadable (%v); starting a fresh chunk, %d bytes discarded", key, uerr, len(body))
179 corruptKey := key + ".corrupt-" + strconv.FormatInt(now.Unix(), 10)
180 if _, perr := s.Put(ctx, corruptKey, body, ""); perr != nil {
181 m.log("relay: could not preserve corrupt meter chunk %s as %s: %v", key, corruptKey, perr)
182 }
183 c = MeterChunk{V: 1, Box: m.box, Hour: hour}
184 }
185 }
186 c.Samples = append(c.Samples, sample)
187 out, _ := json.Marshal(c)
188 if _, err = s.Put(ctx, key, out, etag); err == nil {
189 return nil
190 } else if !errors.Is(err, release.ErrCASConflict) {
191 return err
192 } else {
193 lastErr = err
194 }
195 }
196 return lastErr
197}
198
199// LoadWindow returns every sample from the previous and current hour's
200// objects, for rebuilding rolling-hour budgets after a restart. An hour
201// whose chunk is corrupt is logged via logf (nil discards) and skipped
202// rather than failing the whole load.
203func LoadWindow(ctx context.Context, s release.Store, box string, now time.Time, logf func(string, ...any)) ([]MeterSample, error) {
204 var out []MeterSample
205 for _, h := range []string{metrics.HourOf(now.Add(-time.Hour)), metrics.HourOf(now)} {
206 key := MeterKey(box, h)
207 body, _, err := s.Get(ctx, key)
208 if errors.Is(err, os.ErrNotExist) {
209 continue
210 }
211 if err != nil {
212 return nil, err
213 }
214 var c MeterChunk
215 if uerr := json.Unmarshal(body, &c); uerr != nil {
216 if logf != nil {
217 logf("relay: meter chunk %s unreadable (%v); budgets rebuilt without it", key, uerr)
218 }
219 continue
220 }
221 out = append(out, c.Samples...)
222 }
223 return out, nil
224}
225