carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package applog
2
3import (
4 "bufio"
5 "bytes"
6 "compress/gzip"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/carlosframework/platform/internal/release"
16)
17
18// hourFormat is the UTC hour vocabulary in chunk keys — deliberately the
19// same format internal/metrics uses for its raw chunks, so the bucket
20// speaks one hour dialect.
21const hourFormat = "2006-01-02-15"
22
23// HourOf formats t as the UTC hour directory a chunk belongs to.
24func HourOf(t time.Time) string { return t.UTC().Format(hourFormat) }
25
26// HourBefore reports whether hour (a HourOf-formatted directory name) ends
27// strictly before cutoff's own hour — the retention test. An unparsable
28// name never matches: pruning must not delete on a guess.
29func HourBefore(hour string, cutoff time.Time) bool {
30 t, err := time.ParseInLocation(hourFormat, hour, time.UTC)
31 if err != nil {
32 return false
33 }
34 return t.Add(time.Hour).Sub(cutoff.UTC()) <= 0 && HourOf(cutoff) != hour
35}
36
37// Family names one chunk family under an app — the path segment after
38// apps/<sqid>/<app>/. logs/ is the original; errors/ and analytics/ are
39// the siblings the errors-and-analytics spec adds (§3.2). The key shape
40// below the family segment is identical for every family, which is what
41// lets one prune loop, one store-door grant and one reader serve all three.
42type Family string
43
44const (
45 FamilyLogs Family = "logs"
46 FamilyErrors Family = "errors"
47 FamilyAnalytics Family = "analytics"
48)
49
50// ErrBadKeySegment is every family builder's refusal: an account or app
51// that is not a valid name, or a box or hour that is empty or carries a
52// slash. Writers skip the write on it; readers fail the read. A bad
53// segment never becomes a key, so no box can write apps/<sqid>//... (an
54// app-less route once did exactly that, and hostops.Apps, rightly, refuses
55// to walk a bucket holding it).
56var ErrBadKeySegment = errors.New("applog: refusing to build a key from an empty or invalid segment")
57
58// CheckApp is the account and app half of every family builder's check:
59// the same names hostops.Apps accepts when it walks apps/.
60func CheckApp(acct, app string) error {
61 if !release.ValidAccount(acct) || !release.ValidApp(app) {
62 return fmt.Errorf("%w: account %q, app %q", ErrBadKeySegment, acct, app)
63 }
64 return nil
65}
66
67// CheckSegment refuses a box, hour or date segment that would collapse or
68// split a path level. internal/analytics uses it for its day-file leaf.
69func CheckSegment(what, v string) error {
70 if v == "" || strings.ContainsRune(v, '/') {
71 return fmt.Errorf("%w: %s %q", ErrBadKeySegment, what, v)
72 }
73 return nil
74}
75
76// FamilyPrefix is the app's whole subtree for one family — under
77// release.AppPrefix on purpose (spec §3): it is the app's data, so the
78// owner gate maps onto the prefix and app trash/purge sweeps it for free.
79// An invalid account or app is refused (ErrBadKeySegment), never built.
80func FamilyPrefix(fam Family, acct, app string) (string, error) {
81 if err := CheckApp(acct, app); err != nil {
82 return "", err
83 }
84 return release.AppPrefix(acct, app) + string(fam) + "/", nil
85}
86
87// FamilyBoxPrefix is one box's subtree within a family — the unit of
88// write-side ownership: each box prunes only what it wrote.
89func FamilyBoxPrefix(fam Family, acct, app, box string) (string, error) {
90 p, err := FamilyPrefix(fam, acct, app)
91 if err != nil {
92 return "", err
93 }
94 if err := CheckSegment("box", box); err != nil {
95 return "", err
96 }
97 return p + box + "/", nil
98}
99
100// FamilyHourPrefix is one UTC hour within a box's subtree.
101func FamilyHourPrefix(fam Family, acct, app, box, hour string) (string, error) {
102 p, err := FamilyBoxPrefix(fam, acct, app, box)
103 if err != nil {
104 return "", err
105 }
106 if err := CheckSegment("hour", hour); err != nil {
107 return "", err
108 }
109 return p + hour + "/", nil
110}
111
112// FamilyChunkKey names one flushed chunk in any family. See ChunkKey for
113// why seq and size live in the key.
114func FamilyChunkKey(fam Family, acct, app, box, hour string, seq uint64, size int) (string, error) {
115 p, err := FamilyHourPrefix(fam, acct, app, box, hour)
116 if err != nil {
117 return "", err
118 }
119 return fmt.Sprintf("%s%016d-%d.jsonl.gz", p, seq, size), nil
120}
121
122// Prefix is the app's whole log subtree: FamilyPrefix for logs.
123func Prefix(acct, app string) (string, error) { return FamilyPrefix(FamilyLogs, acct, app) }
124
125// BoxPrefix is one box's subtree within an app's logs.
126func BoxPrefix(acct, app, box string) (string, error) {
127 return FamilyBoxPrefix(FamilyLogs, acct, app, box)
128}
129
130// HourPrefix is one UTC hour within a box's log subtree.
131func HourPrefix(acct, app, box, hour string) (string, error) {
132 return FamilyHourPrefix(FamilyLogs, acct, app, box, hour)
133}
134
135// ChunkKey names one flushed log chunk: seq is the first record's per-box
136// sequence number, zero-padded (16 digits — seq is milliseconds-based, see
137// NewSink) so lexical listing order is numeric order, and size is the
138// object's own byte length. Size lives IN the key on purpose:
139// release.Store's List returns keys only, and retention's byte cap needs
140// sizes — embedding them costs a suffix, extending the Store interface
141// costs every implementation. Chunks are written once and never appended
142// or rewritten (spec §3).
143func ChunkKey(acct, app, box, hour string, seq uint64, size int) (string, error) {
144 return FamilyChunkKey(FamilyLogs, acct, app, box, hour, seq, size)
145}
146
147// ChunkKeySize recovers the byte size a ChunkKey carries, false for any key
148// not shaped like one (retention treats unparsable as size-unknown = 0 —
149// age still prunes it, the byte cap just cannot count it).
150func ChunkKeySize(key string) (int64, bool) {
151 base := key
152 if i := strings.LastIndexByte(base, '/'); i >= 0 {
153 base = base[i+1:]
154 }
155 base, ok := strings.CutSuffix(base, ".jsonl.gz")
156 if !ok {
157 return 0, false
158 }
159 _, sizeStr, ok := strings.Cut(base, "-")
160 if !ok {
161 return 0, false
162 }
163 n, err := strconv.ParseInt(sizeStr, 10, 64)
164 if err != nil || n < 0 {
165 return 0, false
166 }
167 return n, true
168}
169
170// EncodeJSONL renders any record shape as gzipped JSONL, one per line —
171// the chunk codec every family shares.
172func EncodeJSONL[T any](recs []T) ([]byte, error) {
173 var buf bytes.Buffer
174 zw := gzip.NewWriter(&buf)
175 enc := json.NewEncoder(zw)
176 for _, r := range recs {
177 if err := enc.Encode(r); err != nil {
178 return nil, err
179 }
180 }
181 if err := zw.Close(); err != nil {
182 return nil, err
183 }
184 return buf.Bytes(), nil
185}
186
187// DecodeJSONL parses a chunk, skipping malformed lines rather than failing
188// the whole object — one corrupt line must not cost the member the rest of
189// the hour. Corrupt gzip is an error: that is a broken object, not a line.
190func DecodeJSONL[T any](b []byte) ([]T, error) {
191 zr, err := gzip.NewReader(bytes.NewReader(b))
192 if err != nil {
193 return nil, err
194 }
195 defer zr.Close()
196 var out []T
197 sc := bufio.NewScanner(zr)
198 sc.Buffer(nil, 1<<20) // lines are capped well below this at the tee
199 for sc.Scan() {
200 var r T
201 if json.Unmarshal(sc.Bytes(), &r) != nil {
202 continue
203 }
204 out = append(out, r)
205 }
206 if err := sc.Err(); err != nil && err != io.EOF {
207 return out, err
208 }
209 return out, nil
210}
211
212// EncodeChunk renders log records: EncodeJSONL over Record.
213func EncodeChunk(recs []Record) ([]byte, error) { return EncodeJSONL(recs) }
214
215// DecodeChunk parses a log chunk: DecodeJSONL over Record.
216func DecodeChunk(b []byte) ([]Record, error) { return DecodeJSONL[Record](b) }
217