host: ship coalesced errors like logs; prune by chunk family
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+161
−31
internal/host/agent.go+6 −0internal/host/errship.go+109 −0internal/host/errship_test.go+37 −0internal/host/logship.go+7 −22internal/host/logship_test.go+2 −9
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 0208f0dc7..04e45887d 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -166,6 +166,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/apperr" |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/caps" |
| "github.com/carlosframework/platform/internal/cgroup" |
| @@ -232,8 +233,10 @@ func RunAgent(args []string) { |
| // it away is deliberate and cheap (neither branch makes a call), and it |
| // asks precisely the question logshipLoop will ask again for itself. |
| var logSink *applog.Sink |
| + var errSink *apperr.Sink |
| if _, _, err := openStatusStore(context.Background(), os.Getenv); err == nil { |
| logSink = applog.NewSink(status.Label(readMachineID()), time.Now) |
| + errSink = apperr.NewSink(status.Label(readMachineID()), time.Now) |
| } |
| cfg, err := activator.ConfigFromEnv() |
| @@ -383,6 +386,9 @@ func RunAgent(args []string) { |
| if logSink != nil { |
| go logshipLoop(logSink, status.Label(readMachineID())) |
| } |
| + if errSink != nil { |
| + go errshipLoop(errSink, status.Label(readMachineID())) |
| + } |
| go statusLoop(reg) |
| // The memory early-warning (memory-pressure spec §2.4): per box, gated |
| // by CARLOS_HOST_METRICS — one log line and nothing else where unset. |
diff --git a/internal/host/errship.go b/internal/host/errship.go| new file mode 100644 |
| index 000000000..005a936b7 |
| --- /dev/null |
| +++ b/internal/host/errship.go |
| @@ -0,0 +1,109 @@ |
| +// The error shipping loop — the box-side pump of the Errors tab (spec |
| +// 2026-09-07-errors-and-analytics-design.md §3.2), shaped exactly like |
| +// logshipLoop: the edge and the activator coalesce into one apperr.Sink; |
| +// every tick this drains it and writes one immutable chunk per app under |
| +// apps/<sqid>/<app>/errors/<box>/<hour>/; on each hour boundary it prunes |
| +// its own subtree with the log retention (§3.6: errors follow log |
| +// retention, age and byte cap). |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/apperr" |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const errshipTick = 10 * time.Second |
| + |
| +func errshipLoop(sink *apperr.Sink, box string) { |
| + defer func() { |
| + if r := recover(); r != nil { |
| + log.Printf("agent: errship loop panic, error shipping stopped for this process: %v", r) |
| + } |
| + }() |
| + ctx := context.Background() |
| + s, target, err := openStatusStore(ctx, os.Getenv) |
| + if err != nil { |
| + if errors.Is(err, errNoStatusStore) { |
| + log.Printf("agent: errship loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set") |
| + } else { |
| + log.Printf("agent: errship loop disabled: %v", err) |
| + } |
| + return |
| + } |
| + log.Printf("agent: errship loop armed (10s ticks), publishing to %s", target) |
| + |
| + pending := make(map[applog.AppKey][]apperr.Record) |
| + pruned := "" |
| + ticker := time.NewTicker(errshipTick) |
| + defer ticker.Stop() |
| + for range ticker.C { |
| + errshipGather(sink, pending) |
| + errshipFlush(ctx, s, pending) |
| + if hour := applog.HourOf(time.Now()); hour != pruned { |
| + for _, k := range errshipKnownApps(pending, sink) { |
| + chunkPrune(ctx, s, applog.FamilyErrors, box, k, time.Now()) |
| + } |
| + pruned = hour |
| + } |
| + } |
| +} |
| + |
| +// errshipGather drains the sink into pending under the same per-app cap |
| +// logship uses — oldest first. |
| +func errshipGather(sink *apperr.Sink, pending map[applog.AppKey][]apperr.Record) { |
| + for k, recs := range sink.Drain() { |
| + merged := append(pending[k], recs...) |
| + if over := len(merged) - logshipMaxPending; over > 0 { |
| + merged = merged[over:] |
| + } |
| + pending[k] = merged |
| + } |
| +} |
| + |
| +// errshipFlush writes one create-only chunk per pending app and forgets |
| +// what landed; a failed app keeps its records for the next round. |
| +func errshipFlush(ctx context.Context, s release.Store, pending map[applog.AppKey][]apperr.Record) { |
| + for k, recs := range pending { |
| + if len(recs) == 0 { |
| + delete(pending, k) |
| + continue |
| + } |
| + body, err := apperr.EncodeChunk(recs) |
| + if err != nil { |
| + log.Printf("agent: errship encode %s/%s: %v (dropping %d records)", k.Acct, k.App, err, len(recs)) |
| + delete(pending, k) |
| + continue |
| + } |
| + key := apperr.ChunkKey(k.Acct, k.App, recs[0].Box, apperr.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + if _, err := s.Put(ctx, key, body, ""); err != nil { |
| + log.Printf("agent: errship flush %s/%s: %v (%d records held)", k.Acct, k.App, err, len(recs)) |
| + continue |
| + } |
| + delete(pending, k) |
| + } |
| +} |
| + |
| +func errshipKnownApps(pending map[applog.AppKey][]apperr.Record, sink *apperr.Sink) []applog.AppKey { |
| + seen := make(map[applog.AppKey]bool) |
| + var out []applog.AppKey |
| + for k := range pending { |
| + if !seen[k] { |
| + seen[k] = true |
| + out = append(out, k) |
| + } |
| + } |
| + for _, k := range sink.Known() { |
| + if !seen[k] { |
| + seen[k] = true |
| + out = append(out, k) |
| + } |
| + } |
| + return out |
| +} |
diff --git a/internal/host/errship_test.go b/internal/host/errship_test.go| new file mode 100644 |
| index 000000000..83a7e033e |
| --- /dev/null |
| +++ b/internal/host/errship_test.go |
| @@ -0,0 +1,37 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/apperr" |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestErrshipFlushWritesOneChunkPerApp(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) |
| + sink := apperr.NewSink("box1", func() time.Time { return now }) |
| + sink.Add("bab", "web", apperr.Record{Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 GET /x", Host: "h1"}) |
| + sink.Add("bab", "web", apperr.Record{Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 GET /x", Host: "h1"}) |
| + pending := map[applog.AppKey][]apperr.Record{} |
| + errshipGather(sink, pending) |
| + errshipFlush(context.Background(), s, pending) |
| + if len(pending) != 0 { |
| + t.Fatalf("pending after flush = %d", len(pending)) |
| + } |
| + recs, _, err := apperr.ReadWindow(context.Background(), s, "bab", "web", apperr.Filter{Since: now.Add(-time.Minute)}, 100) |
| + if err != nil || len(recs) != 1 || recs[0].Count != 2 { |
| + t.Fatalf("read back = %+v, %v", recs, err) |
| + } |
| +} |
| + |
| +func TestErrshipFlushHoldsOnFailure(t *testing.T) { |
| + pending := map[applog.AppKey][]apperr.Record{{Acct: "bab", App: "web"}: {{TS: time.Now(), Source: apperr.SourceEdge, Name: "x", FP: "f", Box: "box1", Seq: 1}}} |
| + errshipFlush(context.Background(), failingStore{}, pending) |
| + if len(pending) != 1 { |
| + t.Fatal("a failed flush must keep its records for the next round") |
| + } |
| +} |
diff --git a/internal/host/logship.go b/internal/host/logship.go| index 1f097be4d..a0edab3b3 100644 |
| --- a/internal/host/logship.go |
| +++ b/internal/host/logship.go |
| @@ -89,7 +89,7 @@ func logshipLoop(sink *applog.Sink, box string) { |
| // other boxes' passes or the app's own deletion sweep. Sub-cap |
| // apps pay one ListDirs each per hour. |
| for _, k := range logshipKnownApps(pending, sink) { |
| - logshipPrune(ctx, s, box, k, time.Now()) |
| + chunkPrune(ctx, s, applog.FamilyLogs, box, k, time.Now()) |
| } |
| pruned = hour |
| } |
| @@ -153,13 +153,13 @@ func logshipKnownApps(pending map[applog.AppKey][]applog.Record, sink *applog.Si |
| return out |
| } |
| -// logshipPrune enforces retention for ONE app on THIS box's subtree only — |
| -// each box owns what it wrote (spec §5). Hour granularity: an hour goes |
| -// when it is older than the age floor, or (oldest first) while the |
| +// chunkPrune enforces retention for ONE app's ONE family on THIS box's |
| +// subtree only — each box owns what it wrote (spec §5). Hour granularity: an |
| +// hour goes when it is older than the age floor, or (oldest first) while the |
| // subtree's key-embedded sizes total over the byte cap. Errors are logged |
| // and left for the next hourly pass — retention is idempotent. |
| -func logshipPrune(ctx context.Context, s release.Store, box string, k applog.AppKey, now time.Time) { |
| - prefix := applog.BoxPrefix(k.Acct, k.App, box) |
| +func chunkPrune(ctx context.Context, s release.Store, fam applog.Family, box string, k applog.AppKey, now time.Time) { |
| + prefix := applog.FamilyBoxPrefix(fam, k.Acct, k.App, box) |
| hourDirs, err := s.ListDirs(ctx, prefix) |
| if err != nil { |
| log.Printf("agent: logship prune %s: %v", prefix, err) |
| @@ -195,7 +195,7 @@ func logshipPrune(ctx context.Context, s release.Store, box string, k applog.App |
| // hours[0] is oldest. |
| cutoff := now.Add(-logshipMaxAge) |
| for _, h := range hours { |
| - hourName := hourNameOf(h.dir) |
| + hourName := applog.HourNameOfDir(h.dir) |
| drop := applog.HourBefore(hourName, cutoff) || total > logshipMaxBytes |
| if !drop { |
| break // hours are oldest-first; nothing further is droppable |
| @@ -209,18 +209,3 @@ func logshipPrune(ctx context.Context, s release.Store, box string, k applog.App |
| total -= h.bytes |
| } |
| } |
| - |
| -// hourNameOf extracts the hour directory name from its full prefix |
| -// (".../logs/<box>/<hour>/" → "<hour>"). |
| -func hourNameOf(dir string) string { |
| - trimmed := dir |
| - if n := len(trimmed); n > 0 && trimmed[n-1] == '/' { |
| - trimmed = trimmed[:n-1] |
| - } |
| - for i := len(trimmed) - 1; i >= 0; i-- { |
| - if trimmed[i] == '/' { |
| - return trimmed[i+1:] |
| - } |
| - } |
| - return trimmed |
| -} |
diff --git a/internal/host/logship_test.go b/internal/host/logship_test.go| index 4712d30e8..331a7c62e 100644 |
| --- a/internal/host/logship_test.go |
| +++ b/internal/host/logship_test.go |
| @@ -98,7 +98,7 @@ func TestLogshipPrune(t *testing.T) { |
| liveKey := put("box1", "2026-08-10-11", 3, 10) |
| otherBox := put("box2", "2026-08-01-10", 4, 10) // NOT ours to prune |
| - logshipPrune(ctx, s, "box1", k, now) |
| + chunkPrune(ctx, s, applog.FamilyLogs, "box1", k, now) |
| for key, want := range map[string]bool{oldKey: false, bigKey: false, liveKey: true, otherBox: true} { |
| _, _, err := s.Get(ctx, key) |
| @@ -115,13 +115,6 @@ func (failingStore) Put(context.Context, string, []byte, string) (string, error) |
| return "", context.DeadlineExceeded |
| } |
| -// TestHourNameOf pins the prefix→name extraction. |
| -func TestHourNameOf(t *testing.T) { |
| - if got := hourNameOf("apps/bde/ledgerpay/logs/box1/2026-08-10-11/"); got != "2026-08-10-11" { |
| - t.Fatalf("hourNameOf = %q", got) |
| - } |
| -} |
| - |
| // TestLogshipPruneKeepsRecentUnderCap: nothing under both limits is touched. |
| func TestLogshipPruneKeepsRecentUnderCap(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| @@ -132,7 +125,7 @@ func TestLogshipPruneKeepsRecentUnderCap(t *testing.T) { |
| if _, err := s.Put(ctx, key, make([]byte, 10), ""); err != nil { |
| t.Fatal(err) |
| } |
| - logshipPrune(ctx, s, "box1", k, now) |
| + chunkPrune(ctx, s, applog.FamilyLogs, "box1", k, now) |
| if _, _, err := s.Get(ctx, key); err != nil { |
| t.Fatalf("live chunk pruned: %v", err) |
| } |