applog: refuse an empty or invalid segment in every app family key
The apps/<sqid>/<app>/{logs,errors,analytics}/ key builders now return
(key, error) and refuse, with applog.ErrBadKeySegment, an account or app
that fails the names hostops.Apps accepts, or an empty or slash-bearing
box, hour or date. No builder can produce apps/<sqid>//... any more.
Root cause: an app-less route on the dev box leo-climbing
(proof.dev.carlosframework.com, proof.dev.oncarlos.com and
proof2.dev.oncarlos.com: kind instance, account bab, app "", an Addr
upstream at 127.0.0.1:8099 with nothing listening). Each request is a
refused 502 that the edge files under (RouteAccount(rt), rt.App), which
is (bab, ""), and errship wrote it to apps/bab//errors/leo-climbing/...
That prefix makes hostops.Apps, rightly fail-closed, refuse the bucket,
so the claim's activation preflight and migration census refuse on
every box that uses it. The caller passed the right field: the route
really has no app.
Writers skip and log once: logshipFlush, errshipFlush and chunkPrune
drop a refused app's records and make no store call for it; usageFlush
drops a refused analytics delta instead of re-queueing it forever.
Readers (applog/apperr ReadWindow, analytics ReadRange) fail the read;
the console already logs that and shows its existing copy. Valid keys
are byte-identical.
Census: Plan 9 B8 re-digests UpsertDay, errshipFlush and logshipFlush
at main's class and kinds; Plan 14 gains the envelope-only overlay
plan14-root-reads-fix-empty-app-key.tsv for internal/analytics, apperr,
applog and host, loaded last. Both RULING OWED (platform-1e).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25 files changed,
+666
−66
internal/analytics/countries_test.go+2 −2internal/analytics/day.go+26 −5internal/analytics/day_test.go+15 −3internal/analytics/keyrefusal_test.go+49 −0internal/analytics/read.go+10 −2internal/apperr/keyrefusal_test.go+31 −0internal/apperr/read.go+11 −4internal/apperr/read_test.go+14 −2internal/applog/chunk.go+63 −12internal/applog/chunk_test.go+19 −7internal/applog/keyrefusal_test.go+84 −0internal/applog/read.go+5 −1internal/applog/read_test.go+8 −8internal/console/apierrors_test.go+3 −3internal/console/apilogs_test.go+15 −3internal/edge/fleetapi_e2e_test.go+13 −3internal/host/applessroute_test.go+209 −0internal/host/errship.go+6 −1internal/host/logship.go+35 −2internal/host/logship_test.go+15 −3internal/host/usage_loop.go+8 −1internal/provision/plan14_root_reads_test.go+8 −0internal/provision/plan9_b8_census_test.go+6 −3internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv+7 −0logs_e2e_test.go+4 −1
diff --git a/internal/analytics/countries_test.go b/internal/analytics/countries_test.go| index 5995fedbd..38ec5edec 100644 |
| --- a/internal/analytics/countries_test.go |
| +++ b/internal/analytics/countries_test.go |
| @@ -41,7 +41,7 @@ func TestReadRangeMergesCountriesAcrossBoxesDaysAndOldDays(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| put := func(box, date string, d Day) { |
| raw, _ := json.Marshal(d) |
| - if _, err := s.Put(ctx, DayKey("a", "x", box, date), raw, ""); err != nil { |
| + if _, err := s.Put(ctx, must(t)(DayKey("bab", "x", box, date)), raw, ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -49,7 +49,7 @@ func TestReadRangeMergesCountriesAcrossBoxesDaysAndOldDays(t *testing.T) { |
| put("b1", "2026-10-04", Day{V: 1, Date: "2026-10-04", Countries: map[string]int64{}}) |
| put("b1", "2026-10-05", Day{V: 1, Date: "2026-10-05", Countries: map[string]int64{"GB": 3}, GeoSources: map[string]int64{"DBIP-City-Lite": 3}}) |
| put("b2", "2026-10-05", Day{V: 1, Date: "2026-10-05", Countries: map[string]int64{"GB": 1, "IE": 2}, GeoSources: map[string]int64{"GeoLite2-City": 3}}) |
| - sum, err := ReadRange(ctx, s, "a", "x", time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC), time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)) |
| + sum, err := ReadRange(ctx, s, "bab", "x", time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC), time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/analytics/day.go b/internal/analytics/day.go| index 34728aea8..9bbcd9c17 100644 |
| --- a/internal/analytics/day.go |
| +++ b/internal/analytics/day.go |
| @@ -77,14 +77,32 @@ type Delta struct { |
| requeued bool |
| } |
| -// Prefix is the app's analytics/ subtree. |
| -func Prefix(sqid, app string) string { return applog.FamilyPrefix(applog.FamilyAnalytics, sqid, app) } |
| +// Prefix is the app's analytics/ subtree. An invalid account or app is |
| +// refused (applog.ErrBadKeySegment), never built. |
| +func Prefix(sqid, app string) (string, error) { |
| + return applog.FamilyPrefix(applog.FamilyAnalytics, sqid, app) |
| +} |
| // BoxPrefix is one box's day files. |
| -func BoxPrefix(sqid, app, box string) string { return Prefix(sqid, app) + box + "/days/" } |
| +func BoxPrefix(sqid, app, box string) (string, error) { |
| + p, err := applog.FamilyBoxPrefix(applog.FamilyAnalytics, sqid, app, box) |
| + if err != nil { |
| + return "", err |
| + } |
| + return p + "days/", nil |
| +} |
| // DayKey is one day file. |
| -func DayKey(sqid, app, box, date string) string { return BoxPrefix(sqid, app, box) + date + ".json" } |
| +func DayKey(sqid, app, box, date string) (string, error) { |
| + p, err := BoxPrefix(sqid, app, box) |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := applog.CheckSegment("date", date); err != nil { |
| + return "", err |
| + } |
| + return p + date + ".json", nil |
| +} |
| // Merge folds x into d unless x.Seq is not above the recorded seq for |
| // x.Box — the idempotency rule that makes a retried conditional put safe. |
| @@ -146,7 +164,10 @@ func addDrops(a, b DropCounts) DropCounts { |
| // Merge, conditional put; on ErrCASConflict re-read and retry, three |
| // attempts. A no-op merge (seq already recorded) writes nothing. |
| func UpsertDay(ctx context.Context, s release.Store, sqid, app string, x Delta) error { |
| - key := DayKey(sqid, app, x.Box, x.Date) |
| + key, err := DayKey(sqid, app, x.Box, x.Date) |
| + if err != nil { |
| + return err |
| + } |
| for attempt := 0; attempt < 3; attempt++ { |
| var day Day |
| raw, etag, err := s.Get(ctx, key) |
diff --git a/internal/analytics/day_test.go b/internal/analytics/day_test.go| index f702d4000..25ac86a79 100644 |
| --- a/internal/analytics/day_test.go |
| +++ b/internal/analytics/day_test.go |
| @@ -77,7 +77,7 @@ func TestUpsertDayCASAcrossBoxes(t *testing.T) { |
| if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil { // retry of an ambiguous put |
| t.Fatal(err) |
| } |
| - raw, _, err := s.Get(ctx, DayKey("bab", "web", "box-a", "2026-09-07")) |
| + raw, _, err := s.Get(ctx, must(t)(DayKey("bab", "web", "box-a", "2026-09-07"))) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -86,9 +86,21 @@ func TestUpsertDayCASAcrossBoxes(t *testing.T) { |
| if d.Hours["09"].PV != 7 || d.Seq["box-a"] != 2 { |
| t.Fatalf("day = %+v", d) |
| } |
| - if DayKey("bab", "web", "box-a", "2026-09-07") != "apps/bab/web/analytics/box-a/days/2026-09-07.json" { |
| - t.Fatal(DayKey("bab", "web", "box-a", "2026-09-07")) |
| + if k := must(t)(DayKey("bab", "web", "box-a", "2026-09-07")); k != "apps/bab/web/analytics/box-a/days/2026-09-07.json" { |
| + t.Fatal(k) |
| } |
| } |
| func contains(s, sub string) bool { return strings.Contains(s, sub) } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: must(t)(ChunkKey(...)). |
| +func must(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/analytics/keyrefusal_test.go b/internal/analytics/keyrefusal_test.go| new file mode 100644 |
| index 000000000..1b697ceed |
| --- /dev/null |
| +++ b/internal/analytics/keyrefusal_test.go |
| @@ -0,0 +1,49 @@ |
| +package analytics |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestAnalyticsKeysRefuseEmptyApp: the day-file builders refuse an empty |
| +// app, account, box or date, and UpsertDay refuses before touching the |
| +// store, so nothing lands under apps/<sqid>//analytics/. |
| +func TestAnalyticsKeysRefuseEmptyApp(t *testing.T) { |
| + for _, c := range []struct{ acct, app, box, date string }{ |
| + {"bab", "", "box-a", "2026-10-16"}, |
| + {"", "web", "box-a", "2026-10-16"}, |
| + {"bab", "web", "", "2026-10-16"}, |
| + {"bab", "web", "box-a", ""}, |
| + {"bab", "web", "box-a", "2026/10/16"}, |
| + } { |
| + if k, err := DayKey(c.acct, c.app, c.box, c.date); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Errorf("DayKey%+v = (%q, %v)", c, k, err) |
| + } |
| + } |
| + if k, err := Prefix("bab", ""); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("Prefix = (%q, %v)", k, err) |
| + } |
| + if k, err := BoxPrefix("bab", "", "box-a"); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("BoxPrefix = (%q, %v)", k, err) |
| + } |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + err := UpsertDay(ctx, s, "bab", "", Delta{Box: "box-a", Date: "2026-10-16", Seq: 1}) |
| + if !errors.Is(err, applog.ErrBadKeySegment) { |
| + t.Fatalf("UpsertDay = %v", err) |
| + } |
| + if keys, _ := s.List(ctx, "apps/"); len(keys) != 0 { |
| + t.Fatalf("UpsertDay wrote %v", keys) |
| + } |
| + if _, err := ReadRange(ctx, s, "bab", "", time.Now(), time.Now()); !errors.Is(err, applog.ErrBadKeySegment) { |
| + t.Fatalf("ReadRange = %v", err) |
| + } |
| + if got := must(t)(BoxPrefix("bab", "web", "box-a")); got != "apps/bab/web/analytics/box-a/days/" { |
| + t.Fatalf("valid BoxPrefix moved: %q", got) |
| + } |
| +} |
diff --git a/internal/analytics/read.go b/internal/analytics/read.go| index a7fa43814..af9cd99d9 100644 |
| --- a/internal/analytics/read.go |
| +++ b/internal/analytics/read.go |
| @@ -62,7 +62,11 @@ func Dates(from, to time.Time) []string { |
| // day; a store error fails the read. |
| func ReadRange(ctx context.Context, s release.Store, sqid, app string, from, to time.Time) (Summary, error) { |
| sum := Summary{From: usage.DayOf(from), To: usage.DayOf(to)} |
| - boxDirs, err := s.ListDirs(ctx, Prefix(sqid, app)) |
| + prefix, err := Prefix(sqid, app) |
| + if err != nil { |
| + return sum, err |
| + } |
| + boxDirs, err := s.ListDirs(ctx, prefix) |
| if err != nil { |
| return sum, err |
| } |
| @@ -76,7 +80,11 @@ func ReadRange(ctx context.Context, s release.Store, sqid, app string, from, to |
| ds := DaySummary{Date: date} |
| vis := &HLL{} |
| for _, box := range sum.Boxes { |
| - raw, _, err := s.Get(ctx, DayKey(sqid, app, box, date)) |
| + key, err := DayKey(sqid, app, box, date) |
| + if err != nil { |
| + return sum, err |
| + } |
| + raw, _, err := s.Get(ctx, key) |
| if errors.Is(err, os.ErrNotExist) { |
| continue |
| } |
diff --git a/internal/apperr/keyrefusal_test.go b/internal/apperr/keyrefusal_test.go| new file mode 100644 |
| index 000000000..2eebb786b |
| --- /dev/null |
| +++ b/internal/apperr/keyrefusal_test.go |
| @@ -0,0 +1,31 @@ |
| +package apperr |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestErrorsKeysRefuseEmptyApp: the errors family's own builders refuse |
| +// the empty app an app-less route is filed under, and a read for it fails |
| +// rather than listing apps/<sqid>//errors/. |
| +func TestErrorsKeysRefuseEmptyApp(t *testing.T) { |
| + if k, err := Prefix("bab", ""); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("Prefix = (%q, %v)", k, err) |
| + } |
| + if k, err := BoxPrefix("bab", "", "leo-climbing"); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("BoxPrefix = (%q, %v)", k, err) |
| + } |
| + if k, err := ChunkKey("bab", "", "leo-climbing", "2026-10-16-06", 1, 2); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("ChunkKey = (%q, %v)", k, err) |
| + } |
| + if _, _, err := ReadWindow(context.Background(), release.NewDirStore(t.TempDir()), "bab", "", Filter{}, 10); !errors.Is(err, applog.ErrBadKeySegment) { |
| + t.Fatalf("ReadWindow = %v", err) |
| + } |
| + if got := must(t)(ChunkKey("bab", "web", "leo-climbing", "2026-10-16-06", 1792015319229, 257)); got != "apps/bab/web/errors/leo-climbing/2026-10-16-06/0001792015319229-257.jsonl.gz" { |
| + t.Fatalf("valid ChunkKey moved: %q", got) |
| + } |
| +} |
diff --git a/internal/apperr/read.go b/internal/apperr/read.go| index d17608b7e..96aac7cfc 100644 |
| --- a/internal/apperr/read.go |
| +++ b/internal/apperr/read.go |
| @@ -11,15 +11,18 @@ import ( |
| // Prefix is the app's errors/ subtree (spec §3.2): a sibling family of |
| // logs/ with the same key shape below it. |
| -func Prefix(acct, app string) string { return applog.FamilyPrefix(applog.FamilyErrors, acct, app) } |
| +// An invalid account or app is refused (applog.ErrBadKeySegment). |
| +func Prefix(acct, app string) (string, error) { |
| + return applog.FamilyPrefix(applog.FamilyErrors, acct, app) |
| +} |
| // BoxPrefix is one box's subtree within an app's errors. |
| -func BoxPrefix(acct, app, box string) string { |
| +func BoxPrefix(acct, app, box string) (string, error) { |
| return applog.FamilyBoxPrefix(applog.FamilyErrors, acct, app, box) |
| } |
| // ChunkKey names one flushed errors chunk; see applog.ChunkKey. |
| -func ChunkKey(acct, app, box, hour string, seq uint64, size int) string { |
| +func ChunkKey(acct, app, box, hour string, seq uint64, size int) (string, error) { |
| return applog.FamilyChunkKey(applog.FamilyErrors, acct, app, box, hour, seq, size) |
| } |
| @@ -89,7 +92,11 @@ func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter |
| if !listSince.IsZero() { |
| listSince = listSince.Add(-time.Hour) |
| } |
| - perBox, trimmed, err := applog.ReadChunks[Record](ctx, s, Prefix(acct, app), listSince, f.Until, f.Match, collectCap) |
| + prefix, err := Prefix(acct, app) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + perBox, trimmed, err := applog.ReadChunks[Record](ctx, s, prefix, listSince, f.Until, f.Match, collectCap) |
| if err != nil { |
| return nil, false, err |
| } |
diff --git a/internal/apperr/read_test.go b/internal/apperr/read_test.go| index deed9a676..b0deaa25b 100644 |
| --- a/internal/apperr/read_test.go |
| +++ b/internal/apperr/read_test.go |
| @@ -17,7 +17,7 @@ func TestReadWindowMergesAndFilters(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := s.Put(ctx, ChunkKey("bab", "web", box, HourOf(recs[0].TS), recs[0].Seq, len(body)), body, ""); err != nil { |
| + if _, err := s.Put(ctx, must(t)(ChunkKey("bab", "web", box, HourOf(recs[0].TS), recs[0].Seq, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -72,7 +72,7 @@ func TestReadWindowListsHourBeforeSince(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := s.Put(ctx, ChunkKey("bab", "web", "box1", HourOf(recs[0].TS), recs[0].Seq, len(body)), body, ""); err != nil { |
| + if _, err := s.Put(ctx, must(t)(ChunkKey("bab", "web", "box1", HourOf(recs[0].TS), recs[0].Seq, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| since := time.Date(2026, 9, 7, 11, 0, 0, 0, time.UTC) |
| @@ -85,3 +85,15 @@ func TestReadWindowListsHourBeforeSince(t *testing.T) { |
| t.Fatalf("window = %+v, want only flong", got) |
| } |
| } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: must(t)(ChunkKey(...)). |
| +func must(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/applog/chunk.go b/internal/applog/chunk.go| index ef3a8b56b..b7935d7fa 100644 |
| --- a/internal/applog/chunk.go |
| +++ b/internal/applog/chunk.go |
| @@ -5,6 +5,7 @@ import ( |
| "bytes" |
| "compress/gzip" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "io" |
| "strconv" |
| @@ -46,38 +47,88 @@ const ( |
| FamilyAnalytics Family = "analytics" |
| ) |
| +// ErrBadKeySegment is every family builder's refusal: an account or app |
| +// that is not a valid name, or a box or hour that is empty or carries a |
| +// slash. Writers skip the write on it; readers fail the read. A bad |
| +// segment never becomes a key, so no box can write apps/<sqid>//... (an |
| +// app-less route once did exactly that, and hostops.Apps, rightly, refuses |
| +// to walk a bucket holding it). |
| +var ErrBadKeySegment = errors.New("applog: refusing to build a key from an empty or invalid segment") |
| + |
| +// CheckApp is the account and app half of every family builder's check: |
| +// the same names hostops.Apps accepts when it walks apps/. |
| +func CheckApp(acct, app string) error { |
| + if !release.ValidAccount(acct) || !release.ValidApp(app) { |
| + return fmt.Errorf("%w: account %q, app %q", ErrBadKeySegment, acct, app) |
| + } |
| + return nil |
| +} |
| + |
| +// CheckSegment refuses a box, hour or date segment that would collapse or |
| +// split a path level. internal/analytics uses it for its day-file leaf. |
| +func CheckSegment(what, v string) error { |
| + if v == "" || strings.ContainsRune(v, '/') { |
| + return fmt.Errorf("%w: %s %q", ErrBadKeySegment, what, v) |
| + } |
| + return nil |
| +} |
| + |
| // FamilyPrefix is the app's whole subtree for one family — under |
| // release.AppPrefix on purpose (spec §3): it is the app's data, so the |
| // owner gate maps onto the prefix and app trash/purge sweeps it for free. |
| -func FamilyPrefix(fam Family, acct, app string) string { |
| - return release.AppPrefix(acct, app) + string(fam) + "/" |
| +// An invalid account or app is refused (ErrBadKeySegment), never built. |
| +func FamilyPrefix(fam Family, acct, app string) (string, error) { |
| + if err := CheckApp(acct, app); err != nil { |
| + return "", err |
| + } |
| + return release.AppPrefix(acct, app) + string(fam) + "/", nil |
| } |
| // FamilyBoxPrefix is one box's subtree within a family — the unit of |
| // write-side ownership: each box prunes only what it wrote. |
| -func FamilyBoxPrefix(fam Family, acct, app, box string) string { |
| - return FamilyPrefix(fam, acct, app) + box + "/" |
| +func FamilyBoxPrefix(fam Family, acct, app, box string) (string, error) { |
| + p, err := FamilyPrefix(fam, acct, app) |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := CheckSegment("box", box); err != nil { |
| + return "", err |
| + } |
| + return p + box + "/", nil |
| } |
| // FamilyHourPrefix is one UTC hour within a box's subtree. |
| -func FamilyHourPrefix(fam Family, acct, app, box, hour string) string { |
| - return FamilyBoxPrefix(fam, acct, app, box) + hour + "/" |
| +func FamilyHourPrefix(fam Family, acct, app, box, hour string) (string, error) { |
| + p, err := FamilyBoxPrefix(fam, acct, app, box) |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := CheckSegment("hour", hour); err != nil { |
| + return "", err |
| + } |
| + return p + hour + "/", nil |
| } |
| // FamilyChunkKey names one flushed chunk in any family. See ChunkKey for |
| // why seq and size live in the key. |
| -func FamilyChunkKey(fam Family, acct, app, box, hour string, seq uint64, size int) string { |
| - return fmt.Sprintf("%s%016d-%d.jsonl.gz", FamilyHourPrefix(fam, acct, app, box, hour), seq, size) |
| +func FamilyChunkKey(fam Family, acct, app, box, hour string, seq uint64, size int) (string, error) { |
| + p, err := FamilyHourPrefix(fam, acct, app, box, hour) |
| + if err != nil { |
| + return "", err |
| + } |
| + return fmt.Sprintf("%s%016d-%d.jsonl.gz", p, seq, size), nil |
| } |
| // Prefix is the app's whole log subtree: FamilyPrefix for logs. |
| -func Prefix(acct, app string) string { return FamilyPrefix(FamilyLogs, acct, app) } |
| +func Prefix(acct, app string) (string, error) { return FamilyPrefix(FamilyLogs, acct, app) } |
| // BoxPrefix is one box's subtree within an app's logs. |
| -func BoxPrefix(acct, app, box string) string { return FamilyBoxPrefix(FamilyLogs, acct, app, box) } |
| +func BoxPrefix(acct, app, box string) (string, error) { |
| + return FamilyBoxPrefix(FamilyLogs, acct, app, box) |
| +} |
| // HourPrefix is one UTC hour within a box's log subtree. |
| -func HourPrefix(acct, app, box, hour string) string { |
| +func HourPrefix(acct, app, box, hour string) (string, error) { |
| return FamilyHourPrefix(FamilyLogs, acct, app, box, hour) |
| } |
| @@ -89,7 +140,7 @@ func HourPrefix(acct, app, box, hour string) string { |
| // sizes — embedding them costs a suffix, extending the Store interface |
| // costs every implementation. Chunks are written once and never appended |
| // or rewritten (spec §3). |
| -func ChunkKey(acct, app, box, hour string, seq uint64, size int) string { |
| +func ChunkKey(acct, app, box, hour string, seq uint64, size int) (string, error) { |
| return FamilyChunkKey(FamilyLogs, acct, app, box, hour, seq, size) |
| } |
diff --git a/internal/applog/chunk_test.go b/internal/applog/chunk_test.go| index 9ab61b891..fb9d00c32 100644 |
| --- a/internal/applog/chunk_test.go |
| +++ b/internal/applog/chunk_test.go |
| @@ -39,18 +39,18 @@ func TestKeysAndHourFormat(t *testing.T) { |
| if got := HourOf(at); got != "2026-08-10-21" { |
| t.Fatalf("HourOf = %q", got) // same vocabulary as metrics' hourFormat |
| } |
| - if got := Prefix("bde", "ledgerpay"); got != "apps/bde/ledgerpay/logs/" { |
| + if got := must(t)(Prefix("bde", "ledgerpay")); got != "apps/bde/ledgerpay/logs/" { |
| t.Fatalf("Prefix = %q", got) |
| } |
| - if got := BoxPrefix("bde", "ledgerpay", "box1"); got != "apps/bde/ledgerpay/logs/box1/" { |
| + if got := must(t)(BoxPrefix("bde", "ledgerpay", "box1")); got != "apps/bde/ledgerpay/logs/box1/" { |
| t.Fatalf("BoxPrefix = %q", got) |
| } |
| - if got := HourPrefix("bde", "ledgerpay", "box1", "2026-08-10-21"); got != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/" { |
| + if got := must(t)(HourPrefix("bde", "ledgerpay", "box1", "2026-08-10-21")); got != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/" { |
| t.Fatalf("HourPrefix = %q", got) |
| } |
| // Zero-padded so lexical order is numeric order; the byte size rides in |
| // the key so retention can count without a Store interface change. |
| - key := ChunkKey("bde", "ledgerpay", "box1", "2026-08-10-21", 41822, 8123) |
| + key := must(t)(ChunkKey("bde", "ledgerpay", "box1", "2026-08-10-21", 41822, 8123)) |
| if key != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/0000000000041822-8123.jsonl.gz" { |
| t.Fatalf("ChunkKey = %q", key) |
| } |
| @@ -133,13 +133,13 @@ func TestHourBefore(t *testing.T) { |
| } |
| func TestFamilyKeysMirrorLogs(t *testing.T) { |
| - if got, want := FamilyPrefix(FamilyLogs, "bab", "web"), Prefix("bab", "web"); got != want { |
| + if got, want := must(t)(FamilyPrefix(FamilyLogs, "bab", "web")), must(t)(Prefix("bab", "web")); got != want { |
| t.Fatalf("FamilyPrefix(logs) = %q, Prefix = %q", got, want) |
| } |
| - if got, want := FamilyChunkKey(FamilyLogs, "bab", "web", "box1", "2026-09-07-10", 42, 7), ChunkKey("bab", "web", "box1", "2026-09-07-10", 42, 7); got != want { |
| + if got, want := must(t)(FamilyChunkKey(FamilyLogs, "bab", "web", "box1", "2026-09-07-10", 42, 7)), must(t)(ChunkKey("bab", "web", "box1", "2026-09-07-10", 42, 7)); got != want { |
| t.Fatalf("FamilyChunkKey(logs) = %q, ChunkKey = %q", got, want) |
| } |
| - got := FamilyChunkKey(FamilyErrors, "bab", "web", "box1", "2026-09-07-10", 42, 7) |
| + got := must(t)(FamilyChunkKey(FamilyErrors, "bab", "web", "box1", "2026-09-07-10", 42, 7)) |
| want := "apps/bab/web/errors/box1/2026-09-07-10/0000000000000042-7.jsonl.gz" |
| if got != want { |
| t.Fatalf("errors key = %q, want %q", got, want) |
| @@ -176,3 +176,15 @@ func TestEncodeJSONLRoundTripsAnyShape(t *testing.T) { |
| t.Fatalf("EncodeChunk and EncodeJSONL disagree: %+v vs %+v", da, dc) |
| } |
| } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: must(t)(ChunkKey(...)). |
| +func must(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/applog/keyrefusal_test.go b/internal/applog/keyrefusal_test.go| new file mode 100644 |
| index 000000000..5ce3ba3ea |
| --- /dev/null |
| +++ b/internal/applog/keyrefusal_test.go |
| @@ -0,0 +1,84 @@ |
| +package applog |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestFamilyBuildersRefuseBadSegments: no builder in the apps/<sqid>/<app>/ |
| +// families can produce a key from an empty or invalid account or app, or an |
| +// empty or slash-bearing box or hour. The empty app is the regression: an |
| +// app-less route was filed under its account with app "", and the errors |
| +// shipper wrote apps/bab//errors/..., which hostops.Apps refuses to walk. |
| +func TestFamilyBuildersRefuseBadSegments(t *testing.T) { |
| + type build func(acct, app, box, hour string) (string, error) |
| + builders := map[string]build{} |
| + for _, fam := range []Family{FamilyLogs, FamilyErrors, FamilyAnalytics} { |
| + builders["FamilyPrefix/"+string(fam)] = func(acct, app, _, _ string) (string, error) { return FamilyPrefix(fam, acct, app) } |
| + builders["FamilyBoxPrefix/"+string(fam)] = func(acct, app, box, _ string) (string, error) { return FamilyBoxPrefix(fam, acct, app, box) } |
| + builders["FamilyHourPrefix/"+string(fam)] = func(acct, app, box, hour string) (string, error) { |
| + return FamilyHourPrefix(fam, acct, app, box, hour) |
| + } |
| + builders["FamilyChunkKey/"+string(fam)] = func(acct, app, box, hour string) (string, error) { |
| + return FamilyChunkKey(fam, acct, app, box, hour, 1, 2) |
| + } |
| + } |
| + builders["Prefix"] = func(acct, app, _, _ string) (string, error) { return Prefix(acct, app) } |
| + builders["BoxPrefix"] = func(acct, app, box, _ string) (string, error) { return BoxPrefix(acct, app, box) } |
| + builders["HourPrefix"] = func(acct, app, box, hour string) (string, error) { return HourPrefix(acct, app, box, hour) } |
| + builders["ChunkKey"] = func(acct, app, box, hour string) (string, error) { return ChunkKey(acct, app, box, hour, 1, 2) } |
| + |
| + // Each case breaks exactly one segment. A box or hour case only applies |
| + // to builders that take that segment: a prefix above the box level |
| + // never sees the box. |
| + cases := []struct { |
| + name string |
| + acct, app, box, hr string |
| + needsBox, needsHour bool |
| + }{ |
| + {"empty app", "bab", "", "box1", "2026-10-16-06", false, false}, |
| + {"empty account", "", "web", "box1", "2026-10-16-06", false, false}, |
| + {"invalid app", "bab", "Web/x", "box1", "2026-10-16-06", false, false}, |
| + {"invalid account", "b", "web", "box1", "2026-10-16-06", false, false}, |
| + {"empty box", "bab", "web", "", "2026-10-16-06", true, false}, |
| + {"slash in box", "bab", "web", "a/b", "2026-10-16-06", true, false}, |
| + {"empty hour", "bab", "web", "box1", "", false, true}, |
| + {"slash in hour", "bab", "web", "box1", "2026/10", false, true}, |
| + } |
| + for name, b := range builders { |
| + usesBox := !strings.HasPrefix(name, "FamilyPrefix") && name != "Prefix" |
| + usesHour := strings.Contains(name, "Hour") || strings.Contains(name, "ChunkKey") |
| + for _, c := range cases { |
| + if (c.needsBox && !usesBox) || (c.needsHour && !usesHour) { |
| + continue |
| + } |
| + key, err := b(c.acct, c.app, c.box, c.hr) |
| + if !errors.Is(err, ErrBadKeySegment) || key != "" { |
| + t.Errorf("%s with %s = (%q, %v), want (\"\", ErrBadKeySegment)", name, c.name, key, err) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestFamilyKeysByteIdentical pins every builder's output for valid names: |
| +// the refusal must not move a single byte of a key that was already good. |
| +func TestFamilyKeysByteIdentical(t *testing.T) { |
| + m := must(t) |
| + for _, c := range []struct{ got, want string }{ |
| + {m(FamilyPrefix(FamilyErrors, "bab", "web")), "apps/bab/web/errors/"}, |
| + {m(FamilyBoxPrefix(FamilyErrors, "bab", "web", "leo-climbing")), "apps/bab/web/errors/leo-climbing/"}, |
| + {m(FamilyHourPrefix(FamilyErrors, "bab", "web", "leo-climbing", "2026-10-16-06")), "apps/bab/web/errors/leo-climbing/2026-10-16-06/"}, |
| + {m(FamilyChunkKey(FamilyErrors, "bab", "web", "leo-climbing", "2026-10-16-06", 1792015319229, 257)), "apps/bab/web/errors/leo-climbing/2026-10-16-06/0001792015319229-257.jsonl.gz"}, |
| + {m(FamilyPrefix(FamilyAnalytics, "bde", "ledgerpay")), "apps/bde/ledgerpay/analytics/"}, |
| + {m(FamilyBoxPrefix(FamilyAnalytics, "bde", "ledgerpay", "box-a")), "apps/bde/ledgerpay/analytics/box-a/"}, |
| + {m(Prefix("bde", "ledgerpay")), "apps/bde/ledgerpay/logs/"}, |
| + {m(BoxPrefix("bde", "ledgerpay", "box1")), "apps/bde/ledgerpay/logs/box1/"}, |
| + {m(HourPrefix("bde", "ledgerpay", "box1", "2026-08-10-21")), "apps/bde/ledgerpay/logs/box1/2026-08-10-21/"}, |
| + {m(ChunkKey("bde", "ledgerpay", "box1", "2026-08-10-21", 41822, 8123)), "apps/bde/ledgerpay/logs/box1/2026-08-10-21/0000000000041822-8123.jsonl.gz"}, |
| + } { |
| + if c.got != c.want { |
| + t.Errorf("key = %q, want %q", c.got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/applog/read.go b/internal/applog/read.go| index 355dea8a0..207a4946c 100644 |
| --- a/internal/applog/read.go |
| +++ b/internal/applog/read.go |
| @@ -196,7 +196,11 @@ func forEachBounded(ctx context.Context, n int, fn func(i int) error) error { |
| // second return says so. Store errors fail the read — a partial answer |
| // that looks whole is the one thing this must never produce. |
| func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error) { |
| - perBox, trimmed, err := ReadChunks[Record](ctx, s, Prefix(acct, app), f.Since, f.Until, f.Match, collectCap) |
| + prefix, err := Prefix(acct, app) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + perBox, trimmed, err := ReadChunks[Record](ctx, s, prefix, f.Since, f.Until, f.Match, collectCap) |
| if err != nil { |
| return nil, false, err |
| } |
diff --git a/internal/applog/read_test.go b/internal/applog/read_test.go| index 90ef3f1cc..316484154 100644 |
| --- a/internal/applog/read_test.go |
| +++ b/internal/applog/read_test.go |
| @@ -23,7 +23,7 @@ func seedWindow(t *testing.T, s release.Store) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := s.Put(ctx, ChunkKey("bde", "ledgerpay", box, hour, seq, len(body)), body, ""); err != nil { |
| + if _, err := s.Put(ctx, must(t)(ChunkKey("bde", "ledgerpay", box, hour, seq, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -119,11 +119,11 @@ func TestReadChunksWalksAnyFamily(t *testing.T) { |
| } |
| now := time.Date(2026, 9, 7, 10, 30, 0, 0, time.UTC) |
| body, _ := EncodeJSONL([]ev{{TS: now, Name: "boom"}, {TS: now.Add(time.Minute), Name: "skip"}}) |
| - key := FamilyChunkKey(FamilyErrors, "bab", "web", "box1", HourOf(now), 1, len(body)) |
| + key := must(t)(FamilyChunkKey(FamilyErrors, "bab", "web", "box1", HourOf(now), 1, len(body))) |
| if _, err := s.Put(ctx, key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| - perBox, trimmed, err := ReadChunks[ev](ctx, s, FamilyPrefix(FamilyErrors, "bab", "web"), |
| + perBox, trimmed, err := ReadChunks[ev](ctx, s, must(t)(FamilyPrefix(FamilyErrors, "bab", "web")), |
| now.Add(-time.Hour), now.Add(time.Hour), func(e ev) bool { return e.Name != "skip" }, 100) |
| if err != nil || trimmed { |
| t.Fatalf("ReadChunks: %v trimmed=%v", err, trimmed) |
| @@ -132,7 +132,7 @@ func TestReadChunksWalksAnyFamily(t *testing.T) { |
| t.Fatalf("perBox = %+v", perBox) |
| } |
| // An hour outside the window is never listed. |
| - perBox, _, err = ReadChunks[ev](ctx, s, FamilyPrefix(FamilyErrors, "bab", "web"), |
| + perBox, _, err = ReadChunks[ev](ctx, s, must(t)(FamilyPrefix(FamilyErrors, "bab", "web")), |
| now.Add(2*time.Hour), time.Time{}, func(ev) bool { return true }, 100) |
| if err != nil || len(perBox) != 1 || len(perBox[0]) != 0 { |
| t.Fatalf("out-of-window read = %+v, %v", perBox, err) |
| @@ -185,7 +185,7 @@ func TestReadChunksConcurrentIsSerialOrder(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := ChunkKey("bde", "ledgerpay", box, HourOf(hour), seq, len(body)) |
| + key := must(t)(ChunkKey("bde", "ledgerpay", box, HourOf(hour), seq, len(body))) |
| if _, err := dir.Put(ctx, key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -196,7 +196,7 @@ func TestReadChunksConcurrentIsSerialOrder(t *testing.T) { |
| } |
| s := &slowStore{Store: dir} |
| all := func(Record) bool { return true } |
| - perBox, trimmed, err := ReadChunks[Record](ctx, s, Prefix("bde", "ledgerpay"), time.Time{}, time.Time{}, all, 1000) |
| + perBox, trimmed, err := ReadChunks[Record](ctx, s, must(t)(Prefix("bde", "ledgerpay")), time.Time{}, time.Time{}, all, 1000) |
| if err != nil || trimmed || len(perBox) != 3 { |
| t.Fatalf("read: %v trimmed=%v boxes=%d", err, trimmed, len(perBox)) |
| } |
| @@ -213,13 +213,13 @@ func TestReadChunksConcurrentIsSerialOrder(t *testing.T) { |
| t.Fatalf("peak in-flight GETs = %d, want 2..%d", s.peak, readConcurrency) |
| } |
| - perBox, trimmed, err = ReadChunks[Record](ctx, s, Prefix("bde", "ledgerpay"), time.Time{}, time.Time{}, all, 5) |
| + perBox, trimmed, err = ReadChunks[Record](ctx, s, must(t)(Prefix("bde", "ledgerpay")), time.Time{}, time.Time{}, all, 5) |
| if err != nil || !trimmed || len(perBox[0]) != 5 || perBox[0][4].Msg != "box1 h4 c7" || perBox[0][0].Msg != "box1 h4 c3" { |
| t.Fatalf("capped read kept %+v (err %v, trimmed %v)", perBox[0], err, trimmed) |
| } |
| s.fail = someKey |
| - if _, _, err := ReadChunks[Record](ctx, s, Prefix("bde", "ledgerpay"), time.Time{}, time.Time{}, all, 1000); err == nil { |
| + if _, _, err := ReadChunks[Record](ctx, s, must(t)(Prefix("bde", "ledgerpay")), time.Time{}, time.Time{}, all, 1000); err == nil { |
| t.Fatal("a failed chunk must fail the read, never return a partial answer") |
| } |
| } |
diff --git a/internal/console/apierrors_test.go b/internal/console/apierrors_test.go| index bc5dcab34..fe9996790 100644 |
| --- a/internal/console/apierrors_test.go |
| +++ b/internal/console/apierrors_test.go |
| @@ -38,7 +38,7 @@ func seedErrors(t *testing.T, fx *consoleFixture, now time.Time) { |
| {TS: now.Add(-6 * time.Minute), Box: "box1", Host: "titogo.example.com", |
| Stream: applog.StreamPlatform, Level: applog.LevelWarn, Msg: "dropped 12 app log line(s) (rate cap)", Seq: 152}} |
| body, _ := applog.EncodeChunk(warn) |
| - if _, err := fx.rel.Put(context.Background(), applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(warn[0].TS), 150, len(body)), body, ""); err != nil { |
| + if _, err := fx.rel.Put(context.Background(), mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(warn[0].TS), 150, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| e := []apperr.Record{{TS: now.Add(-3 * time.Minute), Box: "box1", Host: "titogo.example.com", |
| @@ -55,7 +55,7 @@ func seedErrors(t *testing.T, fx *consoleFixture, now time.Time) { |
| Name: apperr.AvailabilityName(502, apperr.CauseRefused), Path: "/admin", Status: 502, |
| Release: "v4", FP: "new502", FPV: 1, Count: 5, First: now.Add(-4 * time.Minute), Last: now.Add(-2 * time.Minute), Seq: 203}} |
| eb, _ := apperr.EncodeChunk(e) |
| - if _, err := fx.rel.Put(context.Background(), apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(e[0].TS), 200, len(eb)), eb, ""); err != nil { |
| + if _, err := fx.rel.Put(context.Background(), mustKey(t)(apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(e[0].TS), 200, len(eb))), eb, ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -196,7 +196,7 @@ func TestAppErrorsPageCaps(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := fx.rel.Put(context.Background(), apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(recs[0].TS), 1000, len(body)), body, ""); err != nil { |
| + if _, err := fx.rel.Put(context.Background(), mustKey(t)(apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(recs[0].TS), 1000, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| fx.setUser("paul@keymail.dev") |
diff --git a/internal/console/apilogs_test.go b/internal/console/apilogs_test.go| index 4deb40d38..781213f8a 100644 |
| --- a/internal/console/apilogs_test.go |
| +++ b/internal/console/apilogs_test.go |
| @@ -40,7 +40,7 @@ func seedLogChunks(t *testing.T, fx *consoleFixture, now time.Time) []applog.Rec |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), recs[0].Seq, len(body))) |
| if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -58,7 +58,7 @@ func seedManyLogRecords(t *testing.T, fx *consoleFixture, now time.Time, n int) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := applog.ChunkKey(release.OpsAccount, "titogo", "box-many", applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box-many", applog.HourOf(recs[0].TS), recs[0].Seq, len(body))) |
| if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -133,7 +133,7 @@ func TestAPILogsTailFiltersAndFollow(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(extra[0].TS), 102, len(body)) |
| + key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(extra[0].TS), 102, len(body))) |
| if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -350,3 +350,15 @@ func TestAppLogsPage(t *testing.T) { |
| t.Fatalf("empty window page: %d", status) |
| } |
| } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: mustKey(t)(ChunkKey(...)). |
| +func mustKey(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/edge/fleetapi_e2e_test.go b/internal/edge/fleetapi_e2e_test.go| index f6ab8c353..d28c4ad69 100644 |
| --- a/internal/edge/fleetapi_e2e_test.go |
| +++ b/internal/edge/fleetapi_e2e_test.go |
| @@ -218,7 +218,10 @@ func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) { |
| w.put(t, "accounts/"+w.other+"/account.json", fapiAccountManifest(fapiOtherID, w.other, "Intruder")) |
| otherChannelKey := "apps/" + w.other + "/" + fapiOtherApp + "/channels/stable.json" |
| w.put(t, otherChannelKey, `{"app":"intruder","version":"v9"}`) |
| - otherLogKey := applog.ChunkKey(w.other, fapiOtherApp, fapiBoxA, applog.HourOf(time.Now()), 1, 9) |
| + otherLogKey, err := applog.ChunkKey(w.other, fapiOtherApp, fapiBoxA, applog.HourOf(time.Now()), 1, 9) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| w.put(t, otherLogKey, "not yours") |
| w.put(t, "fleets/other/status/boxes/box-a.json", `{"label":"box-a"}`) |
| @@ -507,7 +510,10 @@ func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) { |
| if err != nil { |
| t.Fatalf("(g) EncodeChunk: %v", err) |
| } |
| - chunkKey := applog.ChunkKey(w.sqid, fapiApp, fapiBoxA, hour, 1, len(chunk)) |
| + chunkKey, err := applog.ChunkKey(w.sqid, fapiApp, fapiBoxA, hour, 1, len(chunk)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if _, err := w.boxA.Put(ctx, chunkKey, chunk, ""); err != nil { |
| t.Fatalf("(g) ship a log chunk through the door: %v", err) |
| } |
| @@ -519,7 +525,11 @@ func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) { |
| if err != nil || len(recs) != 1 || recs[0].Msg != "listening" { |
| t.Fatalf("(g) chunk round trip = %+v, %v", recs, err) |
| } |
| - listed, err := w.boxA.List(ctx, applog.BoxPrefix(w.sqid, fapiApp, fapiBoxA)) |
| + boxPrefix, err := applog.BoxPrefix(w.sqid, fapiApp, fapiBoxA) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + listed, err := w.boxA.List(ctx, boxPrefix) |
| if err != nil || !slices.Contains(listed, chunkKey) { |
| t.Fatalf("(g) list own log subtree = %v, %v; want %s", listed, err, chunkKey) |
| } |
diff --git a/internal/host/applessroute_test.go b/internal/host/applessroute_test.go| new file mode 100644 |
| index 000000000..8bed6c30b |
| --- /dev/null |
| +++ b/internal/host/applessroute_test.go |
| @@ -0,0 +1,209 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "log" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/analytics" |
| + "github.com/carlosframework/platform/internal/apperr" |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// keyLog records every key a shipper touches, so a test can see a key that |
| +// a directory-backed store would have quietly normalised ("a//b" is "a/b" |
| +// on disk). |
| +type keyLog struct { |
| + release.Store |
| + mu sync.Mutex |
| + touched []string |
| +} |
| + |
| +func (k *keyLog) note(key string) { |
| + k.mu.Lock() |
| + k.touched = append(k.touched, key) |
| + k.mu.Unlock() |
| +} |
| + |
| +func (k *keyLog) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + k.note("put " + key) |
| + return k.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (k *keyLog) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + k.note("get " + key) |
| + return k.Store.Get(ctx, key) |
| +} |
| + |
| +func (k *keyLog) List(ctx context.Context, prefix string) ([]string, error) { |
| + k.note("list " + prefix) |
| + return k.Store.List(ctx, prefix) |
| +} |
| + |
| +func (k *keyLog) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + k.note("listdirs " + prefix) |
| + return k.Store.ListDirs(ctx, prefix) |
| +} |
| + |
| +func (k *keyLog) Delete(ctx context.Context, key string) error { |
| + k.note("delete " + key) |
| + return k.Store.Delete(ctx, key) |
| +} |
| + |
| +func (k *keyLog) all() []string { |
| + k.mu.Lock() |
| + defer k.mu.Unlock() |
| + return append([]string(nil), k.touched...) |
| +} |
| + |
| +// captureShipLog swaps the standard logger's output for the test and forgets |
| +// which apps unshippable has reported, so its once-only line is observable. |
| +func captureShipLog(t *testing.T) *bytes.Buffer { |
| + t.Helper() |
| + unshippableSeen.Range(func(k, _ any) bool { unshippableSeen.Delete(k); return true }) |
| + var buf bytes.Buffer |
| + prev, flags := log.Writer(), log.Flags() |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) |
| + return &buf |
| +} |
| + |
| +// TestErrshipAppLessRouteWritesNothing is the dev-bucket regression end to |
| +// end. The route is the shape found on leo-climbing: an instance row with |
| +// an account, no app, and a TCP upstream nobody listens on. Every request |
| +// is a refused 502 that the edge files under (bab, ""). The errors shipper |
| +// must write nothing for it (it once wrote apps/bab//errors/...), prune |
| +// nothing, keep shipping the app beside it, and say so once. |
| +func TestErrshipAppLessRouteWritesNothing(t *testing.T) { |
| + dead := httptest.NewServer(http.NotFoundHandler()) |
| + deadAddr := dead.Listener.Addr().String() |
| + dead.Close() // nothing listens here now: every dial is refused |
| + |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range []registry.Route{ |
| + {Host: "proof.example.com", Kind: "instance", Account: "bab", App: "", Addr: deadAddr, Status: "ready", Channel: "stable"}, |
| + {Host: "web.example.com", Kind: "service", Account: "bab", App: "web", Addr: deadAddr, Status: "ready", Version: "v1"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + e, err := edge.New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sink := apperr.NewSink("leo-climbing", time.Now) |
| + e.EnableErrors(sink) |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + get := func(host string) { |
| + t.Helper() |
| + req, _ := http.NewRequest("GET", srv.URL+"/", nil) |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusBadGateway { |
| + t.Fatalf("%s answered %d, want a refused 502", host, resp.StatusCode) |
| + } |
| + } |
| + |
| + logs := captureShipLog(t) |
| + ctx := context.Background() |
| + s := &keyLog{Store: release.NewDirStore(t.TempDir())} |
| + pending := map[applog.AppKey][]apperr.Record{} |
| + for round := 0; round < 2; round++ { |
| + get("proof.example.com") |
| + get("web.example.com") |
| + errshipGather(sink, pending) |
| + if _, ok := pending[applog.AppKey{Acct: "bab", App: ""}]; !ok { |
| + t.Fatalf("round %d: the edge did not file the app-less route under (bab, \"\"); pending = %v", round, pending) |
| + } |
| + errshipFlush(ctx, s, pending) |
| + if len(pending) != 0 { |
| + t.Fatalf("round %d: pending after flush = %v; a refused key must drop, not hold forever", round, pending) |
| + } |
| + } |
| + for _, k := range errshipKnownApps(pending, sink) { |
| + chunkPrune(ctx, s, applog.FamilyErrors, "leo-climbing", k, time.Now()) |
| + } |
| + |
| + var webPuts int |
| + for _, op := range s.all() { |
| + if strings.Contains(op, "//") { |
| + t.Fatalf("touched a key with an empty segment: %s", op) |
| + } |
| + if strings.HasPrefix(op, "put apps/bab/web/errors/leo-climbing/") { |
| + webPuts++ |
| + } |
| + } |
| + if webPuts != 2 { |
| + t.Fatalf("web chunks written = %d, want 2 (one per round): %v", webPuts, s.all()) |
| + } |
| + if n := strings.Count(logs.String(), `errors: not shipping for account "bab" app ""`); n != 1 { |
| + t.Fatalf("refusal logged %d times, want once:\n%s", n, logs.String()) |
| + } |
| +} |
| + |
| +// TestLogshipAndAnalyticsDropAppLessRecords: the logs and analytics |
| +// shippers take the same refusal: drop, never re-queue, write nothing. |
| +func TestLogshipAndAnalyticsDropAppLessRecords(t *testing.T) { |
| + logs := captureShipLog(t) |
| + ctx := context.Background() |
| + s := &keyLog{Store: release.NewDirStore(t.TempDir())} |
| + bad := applog.AppKey{Acct: "bab", App: ""} |
| + |
| + pending := map[applog.AppKey][]applog.Record{bad: {{TS: time.Now(), Box: "box1", Seq: 1, Msg: "x"}}} |
| + logshipFlush(ctx, s, pending) |
| + if len(pending) != 0 { |
| + t.Fatalf("logship pending = %v", pending) |
| + } |
| + chunkPrune(ctx, s, applog.FamilyLogs, "box1", bad, time.Now()) |
| + |
| + now := time.Date(2026, 10, 16, 6, 46, 0, 0, time.UTC) |
| + am := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now }) |
| + am.Observe(analytics.Obs{Acct: "bab", App: "", Host: "proof.example.com", Method: "GET", Status: 502, Path: "/", When: now}) |
| + am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "web.example.com", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now}) |
| + none := func() map[string]usage.EdgeRow { return nil } |
| + for i := 0; i < 2; i++ { // a second flush proves the bad delta was not re-queued |
| + if err := usageFlush(ctx, s, s, "box1", none, am, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + var webDay bool |
| + for _, op := range s.all() { |
| + if strings.Contains(op, "//") { |
| + t.Fatalf("touched a key with an empty segment: %s", op) |
| + } |
| + if op == "put apps/bab/web/analytics/box1/days/2026-10-16.json" { |
| + webDay = true |
| + } |
| + } |
| + if !webDay { |
| + t.Fatalf("the valid app's day file was not written: %v", s.all()) |
| + } |
| + out := logs.String() |
| + if strings.Count(out, `logs: not shipping for account "bab" app ""`) != 1 || strings.Count(out, `analytics: not shipping for account "bab" app ""`) != 1 { |
| + t.Fatalf("want one refusal line per family:\n%s", out) |
| + } |
| + if strings.Contains(out, "re-queued") { |
| + t.Fatalf("a refused analytics delta was re-queued:\n%s", out) |
| + } |
| +} |
diff --git a/internal/host/errship.go b/internal/host/errship.go| index 005a936b7..97932ab5c 100644 |
| --- a/internal/host/errship.go |
| +++ b/internal/host/errship.go |
| @@ -81,7 +81,12 @@ func errshipFlush(ctx context.Context, s release.Store, pending map[applog.AppKe |
| delete(pending, k) |
| continue |
| } |
| - key := apperr.ChunkKey(k.Acct, k.App, recs[0].Box, apperr.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + key, err := apperr.ChunkKey(k.Acct, k.App, recs[0].Box, apperr.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + if err != nil { |
| + unshippable(applog.FamilyErrors, k, err) |
| + delete(pending, k) |
| + continue |
| + } |
| 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 |
diff --git a/internal/host/logship.go b/internal/host/logship.go| index a0edab3b3..c876cf621 100644 |
| --- a/internal/host/logship.go |
| +++ b/internal/host/logship.go |
| @@ -17,6 +17,7 @@ import ( |
| "errors" |
| "log" |
| "os" |
| + "sync" |
| "time" |
| "github.com/carlosframework/platform/internal/applog" |
| @@ -124,7 +125,12 @@ func logshipFlush(ctx context.Context, s release.Store, pending map[applog.AppKe |
| delete(pending, k) |
| continue |
| } |
| - key := applog.ChunkKey(k.Acct, k.App, recs[0].Box, applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + key, err := applog.ChunkKey(k.Acct, k.App, recs[0].Box, applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + if err != nil { |
| + unshippable(applog.FamilyLogs, k, err) |
| + delete(pending, k) |
| + continue |
| + } |
| if _, err := s.Put(ctx, key, body, ""); err != nil { |
| log.Printf("agent: logship flush %s/%s: %v (%d records held)", k.Acct, k.App, err, len(recs)) |
| continue |
| @@ -159,7 +165,11 @@ func logshipKnownApps(pending map[applog.AppKey][]applog.Record, sink *applog.Si |
| // subtree's key-embedded sizes total over the byte cap. Errors are logged |
| // and left for the next hourly pass — retention is idempotent. |
| 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) |
| + prefix, err := applog.FamilyBoxPrefix(fam, k.Acct, k.App, box) |
| + if err != nil { |
| + unshippable(fam, k, err) |
| + return |
| + } |
| hourDirs, err := s.ListDirs(ctx, prefix) |
| if err != nil { |
| log.Printf("agent: logship prune %s: %v", prefix, err) |
| @@ -209,3 +219,26 @@ func chunkPrune(ctx context.Context, s release.Store, fam applog.Family, box str |
| total -= h.bytes |
| } |
| } |
| + |
| +// unshippableSeen remembers which (family, app) pairs unshippable has |
| +// already reported, so a route that can never ship says so once per |
| +// process rather than every tick. Bounded by the routes a box serves. |
| +var unshippableSeen sync.Map |
| + |
| +type unshippableKey struct { |
| + fam applog.Family |
| + app applog.AppKey |
| +} |
| + |
| +// unshippable reports, once per family and app, that the family's key |
| +// builder refused the app (applog.ErrBadKeySegment): the records are |
| +// dropped, and no write or prune happens for it. The case that put this |
| +// here is a route with no app, filed under its account with an empty app |
| +// name; writing it would make apps/<sqid>//..., which hostops.Apps refuses |
| +// to walk. |
| +func unshippable(fam applog.Family, k applog.AppKey, err error) { |
| + if _, dup := unshippableSeen.LoadOrStore(unshippableKey{fam, k}, true); dup { |
| + return |
| + } |
| + log.Printf("agent: %s: not shipping for account %q app %q, dropping its records (logged once): %v", fam, k.Acct, k.App, err) |
| +} |
diff --git a/internal/host/logship_test.go b/internal/host/logship_test.go| index 331a7c62e..d6792c33a 100644 |
| --- a/internal/host/logship_test.go |
| +++ b/internal/host/logship_test.go |
| @@ -32,7 +32,7 @@ func TestLogshipFlushWritesOneChunkPerApp(t *testing.T) { |
| } |
| for _, app := range []applog.AppKey{{Acct: "bde", App: "ledgerpay"}, {Acct: "bab", App: "kass"}} { |
| - keys, err := s.List(context.Background(), applog.Prefix(app.Acct, app.App)) |
| + keys, err := s.List(context.Background(), mustKey(t)(applog.Prefix(app.Acct, app.App))) |
| if err != nil || len(keys) != 1 { |
| t.Fatalf("chunks for %v: %v (%v)", app, keys, err) |
| } |
| @@ -87,7 +87,7 @@ func TestLogshipPrune(t *testing.T) { |
| k := applog.AppKey{Acct: "bde", App: "ledgerpay"} |
| put := func(box, hour string, seq uint64, size int) string { |
| - key := applog.ChunkKey(k.Acct, k.App, box, hour, seq, size) |
| + key := mustKey(t)(applog.ChunkKey(k.Acct, k.App, box, hour, seq, size)) |
| if _, err := s.Put(ctx, key, make([]byte, size), ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -121,7 +121,7 @@ func TestLogshipPruneKeepsRecentUnderCap(t *testing.T) { |
| ctx := context.Background() |
| now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) |
| k := applog.AppKey{Acct: "bde", App: "ledgerpay"} |
| - key := applog.ChunkKey(k.Acct, k.App, "box1", "2026-08-10-11", 1, 10) |
| + key := mustKey(t)(applog.ChunkKey(k.Acct, k.App, "box1", "2026-08-10-11", 1, 10)) |
| if _, err := s.Put(ctx, key, make([]byte, 10), ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -133,3 +133,15 @@ func TestLogshipPruneKeepsRecentUnderCap(t *testing.T) { |
| t.Fatalf("key layout drifted: %s", key) |
| } |
| } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: mustKey(t)(ChunkKey(...)). |
| +func mustKey(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/host/usage_loop.go b/internal/host/usage_loop.go| index 9ec1d81d8..77442159a 100644 |
| --- a/internal/host/usage_loop.go |
| +++ b/internal/host/usage_loop.go |
| @@ -8,6 +8,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/analytics" |
| + "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/usage" |
| ) |
| @@ -29,7 +30,13 @@ func usageFlush(ctx context.Context, s, raw release.Store, box string, drain fun |
| failed := map[analytics.AppKey][]analytics.Delta{} |
| for k, ds := range am.Drain() { |
| for _, d := range ds { |
| - if uerr := analytics.UpsertDay(ctx, raw, k.Acct, k.App, d); uerr != nil { |
| + uerr := analytics.UpsertDay(ctx, raw, k.Acct, k.App, d) |
| + if errors.Is(uerr, applog.ErrBadKeySegment) { |
| + // No key, so no retry can ever land it: drop, never re-queue. |
| + unshippable(applog.FamilyAnalytics, k, uerr) |
| + continue |
| + } |
| + if uerr != nil { |
| log.Printf("agent: analytics flush %s/%s %s: %v (re-queued)", k.Acct, k.App, d.Date, uerr) |
| failed[k] = append(failed[k], d) |
| } |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index ba4764854..c241be44b 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -786,6 +786,14 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-home-part2.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay fix-empty-app-key (RULING OWED, platform-1e), loaded |
| + // last: envelope-only, at main's dispositions. The apps/<sqid>/<app>/ |
| + // family key builders refuse an empty or invalid segment, and the box |
| + // shippers skip a refused app. No root-read unit changed or was added, so |
| + // the seal below is home-part2's. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| seal = "5d1b9b6addf6c9964ed18b417ee5b3d0d49fd43e1acabb775e054bba4b6e314e" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 8af5ccb11..3641cd3f6 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -650,7 +650,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
| - {"internal/analytics/day.go", "UpsertDay", "nonrecord", "sel:Put", "c29f6c1c71ea18299291c1167e01c6aa9844f07acb52a2771ec62fa327c71d88"}, |
| + // Re-digested for fix-empty-app-key (RULING OWED, platform-1e): DayKey now returns an error for an empty or invalid account, app, box or date, and UpsertDay returns it before any Get or Put. Same single Put of the app-day file; no record key. |
| + {"internal/analytics/day.go", "UpsertDay", "nonrecord", "sel:Put", "ab892656cdbce1c80f0c33441341cf9c94dd22f06716ffffbd6c025100adb1b8"}, |
| {"internal/appconfig/configack.go", "WriteConfigAck", "nonrecord", "sel:Put", "211c8417bdbd6c6721fc503db085e9df073adeba623d9daa1e226554760ef5d1"}, |
| {"internal/appconfig/generation.go", "claimRawTouchGeneration", "nonrecord", "sel:Put", "313a5de17aee1b9ed6a344f6b785637c6978b60552a2243ef208c28eb8401a02"}, |
| {"internal/appconfig/store.go", "applyAt", "nonrecord", "sel:Put", "d234e359f44951240e6c052d87a17587845fb263ef9499dae39dae6c5c763e47"}, |
| @@ -760,9 +761,11 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/claimretire_transport.go", "retirementAcknowledgement", "nonrecord", "sel:Put", "5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416"}, |
| // D1 compares the retained registration's record key to the fresh claim; no record write. |
| {"internal/host/rootexecution_authority.go", "ClaimRootHTTP.executionAuthority", "key-site", "key:RecordKey", "107989f561409fd8c11c2666d0672b8dbeb368842fe00ad6a7b84fece2150438"}, |
| - {"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "82831c08563eab44de07c1c69752555eafe60c70dbf9521d9a4711bc5c104a6b"}, |
| + // Re-digested for fix-empty-app-key (RULING OWED, platform-1e): a chunk key the errors builder refuses (empty or invalid account or app) drops that app's records and logs once; no Put happens for it. Same single Put of the errors chunk; no record key. |
| + {"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "b340b3dede6d5cab08a13a2ee8dbef5bb6dfb4943264e9835489292c479a379d"}, |
| {"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"}, |
| - {"internal/host/logship.go", "logshipFlush", "nonrecord", "sel:Put", "a2fa594c1206028af170a2d0cbedef55ba00a2f1c698a6853e1214cc3b172dd9"}, |
| + // Re-digested for fix-empty-app-key (RULING OWED, platform-1e): as errshipFlush, for the logs family. Same single Put of the log chunk; no record key. |
| + {"internal/host/logship.go", "logshipFlush", "nonrecord", "sel:Put", "214a52ecc3bef193b8edcd6998cdaa75da182d552f4e605f68a5dededa4ffb68"}, |
| {"internal/host/metrics_loop.go", "putAlertState", "nonrecord", "sel:Put", "d5b6e8b34dc5a731d6bbafeac0b7d0c5c20706d6f413dff38088b3321b35a26c"}, |
| // Clears an aborting record through SetRecordMove (MoveGuardedAbortComplete) once nothing is owed under the op, then ReleaseAbort: record first, ruling M4. |
| {"internal/host/movesabort.go", "movesWorker.abortSource", "record-writer", "key:SetRecordMove", "51bafe02fe16f07c1f1e08cfbc224ba21c8277ec065fff1142349afe0dde675b"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv b/internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv| new file mode 100644 |
| index 000000000..7a286b5c4 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv |
| @@ -0,0 +1,7 @@ |
| +# Plan 14 census overlay fix-empty-app-key (branch fix-empty-app-key, 2026-10-16; loaded last, after home-part2). RULING OWED: platform-1e has not ruled on this overlay. Envelope-only, at main's dispositions: no root-read unit is added, removed or changed, so no row and no frozen declaration moves, and the legacy seal stands. |
| +# The fix: the apps/<sqid>/<app>/{logs,errors,analytics}/ key builders (internal/applog FamilyPrefix, FamilyBoxPrefix, FamilyHourPrefix, FamilyChunkKey and their logs, errors and analytics wrappers, and analytics DayKey) return an error, applog.ErrBadKeySegment, for an empty or invalid account or app, or an empty or slash-bearing box, hour or date. They never build a key with an empty segment. An app-less route on the dev box leo-climbing (proof.dev.carlosframework.com: kind instance, account bab, app "", an Addr upstream nobody listens on) had its refused 502s filed under (bab, "") and shipped to apps/bab//errors/, which hostops.Apps refuses to walk. |
| +# Envelopes: internal/applog gains the checks (CheckApp, CheckSegment, ErrBadKeySegment) and ReadWindow returns the builder's error. internal/apperr and internal/analytics change their builder signatures, and their readers (apperr.ReadWindow, analytics.ReadRange) and analytics.UpsertDay return the error before any store call. internal/host's logshipFlush, errshipFlush, chunkPrune and usageFlush skip a refused app and log once (unshippable, unshippableSeen); usageFlush drops a refused analytics delta instead of re-queueing it. None of these reads anything new, from the box or the bucket; the only reads that change are ones a refused key no longer makes. |
| +@coverage internal/analytics e7461d39edd1660289669fdb5185f5231ef52e380978ce1d5765e91878f2c907 |
| +@coverage internal/apperr 8d2d53a1065bcdbdacdc178723b132746d00e580fe056bdc8ff2a5634f2b03d5 |
| +@coverage internal/applog fa9fd3a53e62d989d7fd834c86da8e784a396a991a33fa934f79b2db506dc669 |
| +@coverage internal/host 498a7d4be8bd9c2b566daa4536698b9eee14be7aa6ae14ccd14dba33a88018e1 |
diff --git a/logs_e2e_test.go b/logs_e2e_test.go| index c252c08d6..d0939784f 100644 |
| --- a/logs_e2e_test.go |
| +++ b/logs_e2e_test.go |
| @@ -89,7 +89,10 @@ func TestE2ELogsThroughConsole(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), seq, len(body)) |
| + key, err := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), seq, len(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if _, err := relStore.Put(context.Background(), key, body, ""); err != nil { |
| t.Fatal(err) |
| } |