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 "context"
5 "strings"
6 "sync"
7 "time"
8
9 "github.com/carlosframework/platform/internal/release"
10)
11
12// readConcurrency bounds one read's in-flight bucket calls: listings in
13// the first half, chunk GETs in the second. A day of an app's logs is
14// hundreds of chunks, and fetched one at a time that was the whole of the
15// Errors tab's ~5 s (2026-09-24, titogo); sixteen at once brings it to
16// roughly the slowest few round trips, and bounds what one read can ask of
17// the bucket and hold decoded in memory.
18const readConcurrency = 16
19
20// ReadChunks walks every box's hour directories under prefix (a
21// FamilyPrefix) whose hours intersect [since, until], decodes each chunk as
22// T, keeps what passes keep, and returns one slice per box in listing
23// order. Hour pruning happens at the PREFIX level — an hour outside the
24// window is never listed, which is what makes a 1h read over 7d of history
25// cheap (spec §3). collectCap bounds memory per box: past it the OLDEST
26// records go and trimmed says so. Store errors fail the read — a partial
27// answer that looks whole is the one thing this must never produce.
28//
29// Listings and GETs run readConcurrency at a time, but the answer is
30// assembled strictly in listing order, so it is exactly what a serial walk
31// would return. Chunks are fetched at most readConcurrency ahead of the one
32// being assembled, so memory stays bounded by collectCap plus that window,
33// as it was by collectCap plus one chunk. keep runs on the fetching
34// goroutines and must be safe for concurrent use (a Filter's Match is).
35func ReadChunks[T any](ctx context.Context, s release.Store, prefix string, since, until time.Time, keep func(T) bool, collectCap int) ([][]T, bool, error) {
36 ctx, cancel := context.WithCancel(ctx)
37 defer cancel()
38 boxDirs, err := s.ListDirs(ctx, prefix)
39 if err != nil {
40 return nil, false, err
41 }
42 hourDirs := make([][]string, len(boxDirs))
43 if err := forEachBounded(ctx, len(boxDirs), func(i int) error {
44 dirs, err := s.ListDirs(ctx, boxDirs[i])
45 if err != nil {
46 return err
47 }
48 for _, d := range dirs { // lexically sorted = chronological
49 if hourIntersects(HourNameOfDir(d), since, until) {
50 hourDirs[i] = append(hourDirs[i], d)
51 }
52 }
53 return nil
54 }); err != nil {
55 return nil, false, err
56 }
57 type hour struct {
58 box int
59 dir string
60 }
61 var hours []hour
62 for b, dirs := range hourDirs {
63 for _, d := range dirs {
64 hours = append(hours, hour{b, d})
65 }
66 }
67 hourKeys := make([][]string, len(hours))
68 if err := forEachBounded(ctx, len(hours), func(i int) error {
69 keys, err := s.List(ctx, hours[i].dir)
70 hourKeys[i] = keys // lexically sorted = seq order
71 return err
72 }); err != nil {
73 return nil, false, err
74 }
75 type chunk struct {
76 box int
77 key string
78 }
79 var chunks []chunk
80 for i, keys := range hourKeys {
81 for _, k := range keys {
82 chunks = append(chunks, chunk{hours[i].box, k})
83 }
84 }
85
86 // Fetch ahead, assemble in order. A slot's channel is buffered, so a
87 // fetch whose result is never read (the read failed first) still ends;
88 // the feeder stops at cancel.
89 type fetched struct {
90 recs []T
91 err error
92 }
93 slots := make([]chan fetched, len(chunks))
94 for i := range slots {
95 slots[i] = make(chan fetched, 1)
96 }
97 tokens := make(chan struct{}, readConcurrency)
98 go func() {
99 for i, c := range chunks {
100 select {
101 case tokens <- struct{}{}:
102 case <-ctx.Done():
103 return
104 }
105 go func() {
106 body, _, err := s.Get(ctx, c.key)
107 if err != nil {
108 slots[i] <- fetched{err: err}
109 return
110 }
111 all, err := DecodeJSONL[T](body)
112 if err != nil {
113 slots[i] <- fetched{err: err}
114 return
115 }
116 var kept []T
117 for _, r := range all {
118 if keep(r) {
119 kept = append(kept, r)
120 }
121 }
122 slots[i] <- fetched{recs: kept}
123 }()
124 }
125 }()
126 trimmed := false
127 perBox := make([][]T, len(boxDirs))
128 for i, c := range chunks {
129 var f fetched
130 select {
131 case f = <-slots[i]:
132 case <-ctx.Done():
133 return nil, false, ctx.Err()
134 }
135 <-tokens
136 if f.err != nil {
137 return nil, false, f.err
138 }
139 recs := append(perBox[c.box], f.recs...)
140 if over := len(recs) - collectCap; over > 0 {
141 recs = recs[over:]
142 trimmed = true
143 }
144 perBox[c.box] = recs
145 }
146 return perBox, trimmed, nil
147}
148
149// forEachBounded runs fn(0..n-1) readConcurrency at a time and returns the
150// first error, cancelling nothing itself: fn's own ctx is the caller's, and
151// ReadChunks cancels it on return.
152func forEachBounded(ctx context.Context, n int, fn func(i int) error) error {
153 var (
154 wg sync.WaitGroup
155 mu sync.Mutex
156 firstErr error
157 )
158 tokens := make(chan struct{}, readConcurrency)
159 for i := 0; i < n; i++ {
160 mu.Lock()
161 failed := firstErr != nil
162 mu.Unlock()
163 if failed {
164 break // the read has already failed; start nothing more
165 }
166 select {
167 case tokens <- struct{}{}:
168 case <-ctx.Done():
169 wg.Wait()
170 return ctx.Err()
171 }
172 wg.Add(1)
173 go func() {
174 defer func() { <-tokens; wg.Done() }()
175 if err := fn(i); err != nil {
176 mu.Lock()
177 if firstErr == nil {
178 firstErr = err
179 }
180 mu.Unlock()
181 }
182 }()
183 }
184 wg.Wait()
185 return firstErr
186}
187
188// ReadWindow assembles one app's merged timeline from the bucket: every
189// box's chunks whose hour directories intersect [f.Since, f.Until], decoded,
190// filtered through f, merged in (ts, box, seq) order. Hour pruning happens
191// at the PREFIX level — an hour outside the window is never listed, which
192// is what makes a 1h read over 7d of history cheap (spec §3).
193//
194// collectCap bounds memory against a pathological window: past it the
195// OLDEST records go (the tail is what a log reader came for), and the
196// second return says so. Store errors fail the read — a partial answer
197// that looks whole is the one thing this must never produce.
198func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error) {
199 prefix, err := Prefix(acct, app)
200 if err != nil {
201 return nil, false, err
202 }
203 perBox, trimmed, err := ReadChunks[Record](ctx, s, prefix, f.Since, f.Until, f.Match, collectCap)
204 if err != nil {
205 return nil, false, err
206 }
207 merged := MergeRecords(perBox...)
208 if over := len(merged) - collectCap; over > 0 {
209 merged = merged[over:]
210 trimmed = true
211 }
212 return merged, trimmed, nil
213}
214
215// hourIntersects reports whether the hour directory named hour overlaps
216// [since, until] (zero = unbounded). Unparsable names never match — an
217// alien directory is not silently read as data.
218func hourIntersects(hour string, since, until time.Time) bool {
219 start, err := time.ParseInLocation(hourFormat, hour, time.UTC)
220 if err != nil {
221 return false
222 }
223 end := start.Add(time.Hour)
224 if !since.IsZero() && !end.After(since) {
225 return false
226 }
227 if !until.IsZero() && start.After(until) {
228 return false
229 }
230 return true
231}
232
233// HourNameOfDir extracts the final path segment of a "/"-terminated prefix.
234func HourNameOfDir(dir string) string {
235 trimmed := strings.TrimSuffix(dir, "/")
236 if i := strings.LastIndexByte(trimmed, '/'); i >= 0 {
237 return trimmed[i+1:]
238 }
239 return trimmed
240}
241