host: usage flush loop rides the agent, 5min cadence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+106
−0
internal/host/agent.go+3 −0internal/host/usage_loop.go+55 −0internal/host/usage_loop_test.go+48 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 23fb23f62..09b9aaf10 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -315,6 +315,9 @@ func RunAgent(args []string) { |
| // calling it directly would block the front door forever. |
| edge.RunWithActivatorReady(args, act, cache, func(e *edge.Edge) { |
| go tunnelLoop(e) |
| + m := edge.NewUsageMeter() |
| + e.EnableUsage(m) |
| + go usageLoop(m.Drain) |
| }) |
| // The edge has drained; take the children down too (they're this process's |
diff --git a/internal/host/usage_loop.go b/internal/host/usage_loop.go| new file mode 100644 |
| index 000000000..b3af91669 |
| --- /dev/null |
| +++ b/internal/host/usage_loop.go |
| @@ -0,0 +1,55 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// usageFlush drains the edge meter and CAS-merges the deltas into the box's |
| +// usage/edge day object. One flush; the loop below owns cadence. |
| +func usageFlush(ctx context.Context, s release.Store, box string, drain func() map[string]usage.EdgeRow, now time.Time) error { |
| + return usage.UpsertEdgeDay(ctx, s, box, now, drain()) |
| +} |
| + |
| +// usageLoop flushes edge usage every 5 minutes, forever — the metricsLoop |
| +// conventions exactly: recover guard, disabled with a log line if the box |
| +// has no status store (mirroring metricsLoop's errNoStatusStore vs. other-error |
| +// distinction at metrics_loop.go:773-779, rather than the brief's single log |
| +// line, so the two loops read the same way in the logs), errors logged and |
| +// never fatal (the drained window is lost, the next one is not; modelling |
| +// grade tolerates the gap). |
| +func usageLoop(drain func() map[string]usage.EdgeRow) { |
| + defer func() { |
| + if r := recover(); r != nil { |
| + log.Printf("agent: usage loop panic, edge metering stopped for this process: %v", r) |
| + } |
| + }() |
| + ctx := context.Background() |
| + // Same selection rule statusLoop/metricsLoop use (agent.go's |
| + // openStatusStore): a status/deployment bucket wins and keeps direct S3; |
| + // a fleet box with neither set publishes through the edge's store door. |
| + raw, target, err := openStatusStore(ctx, os.Getenv) |
| + if err != nil { |
| + if errors.Is(err, errNoStatusStore) { |
| + log.Printf("agent: usage loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set") |
| + } else { |
| + log.Printf("agent: usage loop disabled: %v", err) |
| + } |
| + return |
| + } |
| + s := release.NewPrefixStore(raw, resolveStatusPrefix(os.Getenv)) |
| + box := metricsBoxLabel(os.Getenv, readMachineID()) |
| + log.Printf("agent: usage loop armed (5min flush), publishing to %s", target) |
| + for { |
| + time.Sleep(5 * time.Minute) |
| + if err := usageFlush(ctx, s, box, drain, time.Now()); err != nil { |
| + log.Printf("agent: usage flush: %v", err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/usage_loop_test.go b/internal/host/usage_loop_test.go| new file mode 100644 |
| index 000000000..9208f6f64 |
| --- /dev/null |
| +++ b/internal/host/usage_loop_test.go |
| @@ -0,0 +1,48 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +func TestUsageFlushWritesAndReflushesMerge(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + at := time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC) |
| + rows := map[string]usage.EdgeRow{ |
| + "a.example.com": {Account: "bab", App: "hello", Requests: 3, BytesOut: 300}, |
| + } |
| + if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return rows }, at); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return rows }, at.Add(5*time.Minute)); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := s.Get(ctx, usage.EdgeDayKey("box1", "2026-08-11")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var day usage.EdgeDay |
| + if err := json.Unmarshal(raw, &day); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := day.Hours["09"]["a.example.com"].Requests; got != 6 { |
| + t.Fatalf("requests = %d, want 6", got) |
| + } |
| +} |
| + |
| +func TestUsageFlushEmptyDrainWritesNothing(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + err := usageFlush(context.Background(), s, "box1", func() map[string]usage.EdgeRow { return nil }, time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if keys, _ := s.List(context.Background(), "usage/"); len(keys) != 0 { |
| + t.Fatalf("wrote %v", keys) |
| + } |
| +} |