carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The log shipping loop — the box-side pump of `carlos logs` (spec
2// 2026-08-10-carlos-logs-design.md §5). Producers (the activator's exec
3// tees and platform events) buffer into one applog.Sink; every 10s — or
4// within ~2s of a platform event, which is what lets `carlos deploy`'s
5// timeout window see the wake error — this loop drains it and writes one
6// immutable gzipped chunk per app into the deployment bucket. On each hour
7// boundary it prunes ITS OWN box subtree per app: hours beyond the age
8// floor, then oldest-first beyond the byte cap (sizes ride in the chunk
9// keys — applog.ChunkKeySize — so no Store interface change). The
10// sampler/flush split mirrors metricsLoop for the same reason: the loop
11// shares a process with the box's traffic, so it recovers panics and
12// disables itself without a bucket rather than taking the box down.
13package host
14
15import (
16 "context"
17 "errors"
18 "log"
19 "os"
20 "sync"
21 "time"
22
23 "github.com/carlosframework/platform/internal/applog"
24 "github.com/carlosframework/platform/internal/release"
25)
26
27const (
28 logshipTick = 10 * time.Second
29 logshipDebounce = 2 * time.Second // event-poke settle: batch a wake's burst into one PUT
30 logshipMaxAge = 7 * 24 * time.Hour
31 logshipMaxBytes = 50 << 20 // per app per box
32 // logshipMaxPending caps records held across failed flushes, per app.
33 // The bucket being unwritable must not grow the agent's heap forever
34 // (samplerMaxBuf's reasoning); oldest go first, silently — a box-wide
35 // bucket outage is loudly visible elsewhere, and the gap in seq numbers
36 // records that something is missing.
37 logshipMaxPending = 4096
38)
39
40func logshipLoop(sink *applog.Sink, box string) {
41 // Same rule as metricsLoop: this loop shares a process with the agent
42 // serving traffic — log shipping stopping is a bad day, the box
43 // stopping is an outage.
44 defer func() {
45 if r := recover(); r != nil {
46 log.Printf("agent: logship loop panic, log shipping stopped for this process: %v", r)
47 }
48 }()
49
50 ctx := context.Background()
51 // Same store selection as statusLoop/metricsLoop (agent.go's
52 // openStatusStore): a bucket wins, and a cut-over fleet box with no bucket
53 // ships through the edge's store door instead of going silent.
54 //
55 // The keys stay UNPREFIXED — deliberately unlike status and metrics, which
56 // rebase under CARLOS_STATUS_PREFIX. A log chunk is the app's own data
57 // (applog.Prefix = release.AppPrefix + "logs/"), and the console reads it
58 // straight from the platform layout (applog.ReadWindow over
59 // apps/<sqid>/<app>/logs/**); rebasing under fleets/<name>/ would file a
60 // fleet box's logs somewhere `carlos logs` never looks. The door's write
61 // ACL admits exactly this shape for the box's own account (aclWrite's
62 // segment-exact apps/<sqid>/<app>/logs/** scope).
63 s, target, err := openStatusStore(ctx, os.Getenv)
64 if err != nil {
65 if errors.Is(err, errNoStatusStore) {
66 log.Printf("agent: logship loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set")
67 } else {
68 log.Printf("agent: logship loop disabled: %v", err)
69 }
70 return
71 }
72 log.Printf("agent: logship loop armed (10s ticks, event fast-flush), publishing to %s", target)
73
74 pending := make(map[applog.AppKey][]applog.Record)
75 pruned := "" // last hour a prune ran for, "" = not yet
76 ticker := time.NewTicker(logshipTick)
77 defer ticker.Stop()
78 for {
79 select {
80 case <-ticker.C:
81 case <-sink.Wake():
82 time.Sleep(logshipDebounce) // let the event's burst finish
83 }
84 logshipGather(sink, pending)
85 logshipFlush(ctx, s, pending)
86
87 if hour := applog.HourOf(time.Now()); hour != pruned {
88 // Prune covers every app this box is CURRENTLY holding or has
89 // just flushed; an app that stops logging entirely ages out via
90 // other boxes' passes or the app's own deletion sweep. Sub-cap
91 // apps pay one ListDirs each per hour.
92 for _, k := range logshipKnownApps(pending, sink) {
93 chunkPrune(ctx, s, applog.FamilyLogs, box, k, time.Now())
94 }
95 pruned = hour
96 }
97 }
98}
99
100// logshipGather drains the sink into pending, enforcing the per-app cap —
101// oldest first, so what survives a long bucket outage is the freshest tail.
102func logshipGather(sink *applog.Sink, pending map[applog.AppKey][]applog.Record) {
103 for k, recs := range sink.Drain() {
104 merged := append(pending[k], recs...)
105 if over := len(merged) - logshipMaxPending; over > 0 {
106 merged = merged[over:]
107 }
108 pending[k] = merged
109 }
110}
111
112// logshipFlush writes one chunk per pending app — create-only, keyed by the
113// batch's first seq, so a retried batch can never overwrite a delivered one
114// — and forgets what landed. A failed app keeps its records for the next
115// round; other apps' flushes proceed regardless.
116func logshipFlush(ctx context.Context, s release.Store, pending map[applog.AppKey][]applog.Record) {
117 for k, recs := range pending {
118 if len(recs) == 0 {
119 delete(pending, k)
120 continue
121 }
122 body, err := applog.EncodeChunk(recs)
123 if err != nil { // marshal of our own records — a bug, not a retry case
124 log.Printf("agent: logship encode %s/%s: %v (dropping %d records)", k.Acct, k.App, err, len(recs))
125 delete(pending, k)
126 continue
127 }
128 key, err := applog.ChunkKey(k.Acct, k.App, recs[0].Box, applog.HourOf(recs[0].TS), recs[0].Seq, len(body))
129 if err != nil {
130 unshippable(applog.FamilyLogs, k, err)
131 delete(pending, k)
132 continue
133 }
134 if _, err := s.Put(ctx, key, body, ""); err != nil {
135 log.Printf("agent: logship flush %s/%s: %v (%d records held)", k.Acct, k.App, err, len(recs))
136 continue
137 }
138 delete(pending, k)
139 }
140}
141
142// logshipKnownApps is the union of apps with pending records and apps the
143// sink has ever buffered for — the prune worklist.
144func logshipKnownApps(pending map[applog.AppKey][]applog.Record, sink *applog.Sink) []applog.AppKey {
145 seen := make(map[applog.AppKey]bool)
146 var out []applog.AppKey
147 for k := range pending {
148 if !seen[k] {
149 seen[k] = true
150 out = append(out, k)
151 }
152 }
153 for _, k := range sink.Known() {
154 if !seen[k] {
155 seen[k] = true
156 out = append(out, k)
157 }
158 }
159 return out
160}
161
162// chunkPrune enforces retention for ONE app's ONE family on THIS box's
163// subtree only — each box owns what it wrote (spec §5). Hour granularity: an
164// hour goes when it is older than the age floor, or (oldest first) while the
165// subtree's key-embedded sizes total over the byte cap. Errors are logged
166// and left for the next hourly pass — retention is idempotent.
167func chunkPrune(ctx context.Context, s release.Store, fam applog.Family, box string, k applog.AppKey, now time.Time) {
168 prefix, err := applog.FamilyBoxPrefix(fam, k.Acct, k.App, box)
169 if err != nil {
170 unshippable(fam, k, err)
171 return
172 }
173 hourDirs, err := s.ListDirs(ctx, prefix)
174 if err != nil {
175 log.Printf("agent: logship prune %s: %v", prefix, err)
176 return
177 }
178 // ListDirs returns full prefixes ending in "/", sorted is not promised —
179 // the hour names sort chronologically, so order them ourselves via the
180 // keys' lexical shape. Collect per-hour keys+sizes as we go.
181 type hourEnt struct {
182 dir string
183 keys []string
184 bytes int64
185 }
186 var hours []hourEnt
187 var total int64
188 for _, dir := range hourDirs {
189 keys, err := s.List(ctx, dir)
190 if err != nil {
191 log.Printf("agent: logship prune %s: %v", dir, err)
192 return
193 }
194 var b int64
195 for _, key := range keys {
196 if n, ok := applog.ChunkKeySize(key); ok {
197 b += n
198 }
199 }
200 hours = append(hours, hourEnt{dir: dir, keys: keys, bytes: b})
201 total += b
202 }
203 // hourDirs from both implementations come lexically sorted (S3 lists in
204 // key order; DirStore sorts), and hour names are zero-padded UTC — so
205 // hours[0] is oldest.
206 cutoff := now.Add(-logshipMaxAge)
207 for _, h := range hours {
208 hourName := applog.HourNameOfDir(h.dir)
209 drop := applog.HourBefore(hourName, cutoff) || total > logshipMaxBytes
210 if !drop {
211 break // hours are oldest-first; nothing further is droppable
212 }
213 for _, key := range h.keys {
214 if err := s.Delete(ctx, key); err != nil {
215 log.Printf("agent: logship prune %s: %v", key, err)
216 return
217 }
218 }
219 total -= h.bytes
220 }
221}
222
223// unshippableSeen remembers which (family, app) pairs unshippable has
224// already reported, so a route that can never ship says so once per
225// process rather than every tick. Bounded by the routes a box serves.
226var unshippableSeen sync.Map
227
228type unshippableKey struct {
229 fam applog.Family
230 app applog.AppKey
231}
232
233// unshippable reports, once per family and app, that the family's key
234// builder refused the app (applog.ErrBadKeySegment): the records are
235// dropped, and no write or prune happens for it. The case that put this
236// here is a route with no app, filed under its account with an empty app
237// name; writing it would make apps/<sqid>//..., which hostops.Apps refuses
238// to walk.
239func unshippable(fam applog.Family, k applog.AppKey, err error) {
240 if _, dup := unshippableSeen.LoadOrStore(unshippableKey{fam, k}, true); dup {
241 return
242 }
243 log.Printf("agent: %s: not shipping for account %q app %q, dropping its records (logged once): %v", fam, k.Acct, k.App, err)
244}
245