carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package provision
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log"
8 "maps"
9 "os"
10 "slices"
11 "sort"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "time"
16
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// RecordView keeps the record families of the deployment bucket in memory
21// and follows the change index so a pass re-reads only the scopes that moved
22// (S3 change-index design §4). It holds, per account, the accounts/<a>/
23// listing and account.json; per app, the instances/ and sidecars/ listings
24// with every record body, the instances and sidecars manifests and the trash
25// marker. Consumers never read it directly: they take a Snapshot and run the
26// unchanged walk code (Reconcile, ReconcileSidecars, ListAllRecords*) over
27// Snapshot().Store(live).
28//
29// Two maps drive it (§4.2): the watch's observed generations and the view's
30// own applied ones. A scope whose applied generation is behind the observed
31// one, or that is new, or whose last re-read failed, is re-read on the next
32// Refresh, whether or not the index itself moved. Poll's moved list is never
33// used: several consumers share one watch, and whichever polls first takes it.
34type RecordView struct {
35 live release.Store
36 watch *release.ChangeWatch
37 // pollTimeout bounds each index poll (release.ChangeIndexPollTimeout;
38 // tests shorten it).
39 pollTimeout time.Duration
40
41 // mu serializes Refresh and Rebuild for their whole duration, the backstop
42 // walk included, so a publication always reflects reads that began after
43 // the previous publication's (§4.3). It is a leaf: nothing else is taken
44 // while it is held (the watch's own locks are leaves below it). The fields
45 // after it are guarded by it.
46 mu sync.Mutex
47 applied map[string]uint64 // scope -> the generation the view's contents reflect
48 pending map[string]bool // scopes whose last read failed: re-read on every Refresh
49 rebuildDue bool // the last walk failed or could not list an account's apps
50 epoch uint64
51
52 snap atomic.Pointer[ViewSnapshot]
53}
54
55// recordViewRebuildHook, when set, runs inside Rebuild after the walk has
56// read everything and before it publishes, with mu held. Tests only.
57var recordViewRebuildHook func()
58
59// NewRecordView returns a view over live that follows watch. It has published
60// nothing: Snapshot is nil until the first Refresh or Rebuild succeeds.
61func NewRecordView(live release.Store, watch *release.ChangeWatch) *RecordView {
62 return &RecordView{live: live, watch: watch, pollTimeout: release.ChangeIndexPollTimeout, applied: map[string]uint64{}, pending: map[string]bool{}}
63}
64
65// Snapshot is the latest publication, or nil before the first. It never
66// blocks on a refresh in progress.
67func (v *RecordView) Snapshot() *ViewSnapshot { return v.snap.Load() }
68
69// ScopeApp parses an app change scope, apps/<a>/<b>.
70func ScopeApp(scope string) (acct, app string, ok bool) {
71 rest, ok := strings.CutPrefix(scope, "apps/")
72 if !ok {
73 return "", "", false
74 }
75 acct, app, ok = strings.Cut(rest, "/")
76 if !ok || acct == "" || app == "" || strings.Contains(app, "/") {
77 return "", "", false
78 }
79 return acct, app, true
80}
81
82// scopeAccount parses an account change scope, accounts/<a>.
83func scopeAccount(scope string) (string, bool) {
84 a, ok := strings.CutPrefix(scope, "accounts/")
85 if !ok || a == "" || strings.Contains(a, "/") {
86 return "", false
87 }
88 return a, true
89}
90
91// viewScope reports whether the view keeps scope's contents. The control/
92// scopes belong to other readers.
93func viewScope(scope string) bool {
94 if _, _, ok := ScopeApp(scope); ok {
95 return true
96 }
97 _, ok := scopeAccount(scope)
98 return ok
99}
100
101// Refresh brings the view up to the change index (§4.2): one conditional GET
102// of the index, then a re-read of every scope whose applied generation is
103// behind the observed one, that is new, or whose last re-read failed. A view
104// that was never built, whose last walk was partial, or whose watch is
105// unusable (no index support, a denied or malformed index) runs Rebuild
106// instead, which is the walk every pass ran before the index (§3.4).
107//
108// A scope that fails to re-read keeps its applied generation, is retried on
109// the next Refresh even when the index answers 304, and is published with the
110// failure cached, so the snapshot is not Complete and a strict reader over it
111// fails as it would on the bucket. A refresh whose ctx ends mid-read still
112// publishes, with the reads that died cached as failures, and returns the
113// ctx error. A refresh that walks and fails publishes the walk's failure
114// (see Rebuild). Either way, no snapshot stays published as Complete once the
115// view knows it is behind: consumers decide on Complete, and a strict reader
116// over the snapshot fails where it would fail on the bucket.
117func (v *RecordView) Refresh(ctx context.Context) error {
118 v.mu.Lock()
119 defer v.mu.Unlock()
120 prev := v.snap.Load()
121 if prev == nil || v.rebuildDue {
122 return v.rebuildLocked(ctx, true)
123 }
124 if err := v.poll(ctx); err != nil {
125 log.Printf("provision: record view: change index unusable (%v): walking", err)
126 return v.rebuildLocked(ctx, false)
127 }
128 obs, ok := v.watch.Observed()
129 if !ok {
130 return v.rebuildLocked(ctx, false)
131 }
132 var due []string
133 for scope, gen := range obs {
134 if have, ok := v.applied[scope]; viewScope(scope) && (!ok || have < gen) {
135 due = append(due, scope)
136 }
137 }
138 for scope := range v.pending {
139 if !slices.Contains(due, scope) {
140 due = append(due, scope)
141 }
142 }
143 if len(due) == 0 {
144 return nil
145 }
146 sort.Strings(due)
147
148 var mu sync.Mutex
149 results := make(map[string]scopeRead, len(due))
150 forEach(due, func(scope string) {
151 r := v.readScope(ctx, prev, scope)
152 mu.Lock()
153 results[scope] = r
154 mu.Unlock()
155 })
156 next := prev.clone()
157 for _, scope := range due {
158 r := results[scope]
159 if a, ok := scopeAccount(scope); ok {
160 next.setAccount(a, r.acct)
161 } else if a, b, ok := ScopeApp(scope); ok {
162 next.setApp(a, b, r.app)
163 }
164 if r.ok {
165 v.applied[scope] = obs[scope]
166 delete(v.pending, scope)
167 } else {
168 log.Printf("provision: record view: re-reading %s failed: retrying on the next refresh", scope)
169 v.pending[scope] = true
170 }
171 }
172 // Reads that died with the caller are cached as failures and the scopes
173 // stay pending: the published snapshot is incomplete, never stale-complete.
174 v.publish(next)
175 return ctx.Err()
176}
177
178// Rebuild is the backstop (§4.4): under mu it polls the index, takes the
179// observed generations, walks every account and app, publishes, and sets
180// applied to the generations read BEFORE the walk, so a bump that lands
181// during the walk is re-read by the next Refresh, never skipped. Unchanged
182// bodies already in the view are kept by ETag rather than read again.
183//
184// A walk whose top-level listing fails (accounts/, or apps/ on a bucket-shaped
185// store) returns the error and publishes the previous snapshot again with that
186// error cached: List("accounts/") and ListDirs("apps/") over it answer the
187// walk's failure, so knownAccounts, Reconcile and the strict readers fail over
188// it exactly as they fail on the bucket, and it is not Complete. The next
189// Refresh walks again. A view that never published stays unpublished.
190func (v *RecordView) Rebuild(ctx context.Context) error {
191 v.mu.Lock()
192 defer v.mu.Unlock()
193 return v.rebuildLocked(ctx, true)
194}
195
196// poll is one index poll, bounded by pollTimeout inside ctx: it runs under
197// mu, and the callers' contexts are long-lived, so a stalled index GET must
198// not hold the view (and with it every reconcile pass) or the watch's shared
199// poll slot. A poll that runs out marks the watch unknown, and the caller
200// walks.
201func (v *RecordView) poll(ctx context.Context) error {
202 pctx, cancel := context.WithTimeout(ctx, v.pollTimeout)
203 defer cancel()
204 _, err := v.watch.Poll(pctx)
205 return err
206}
207
208// rebuildLocked is Rebuild under mu. poll is false when the caller's own
209// poll just failed: the watch is unknown, so a second poll could only spend
210// another bound before the same walk.
211func (v *RecordView) rebuildLocked(ctx context.Context, poll bool) error {
212 if poll {
213 if err := v.poll(ctx); err != nil {
214 log.Printf("provision: record view: change index unusable (%v): the walk records no generations", err)
215 }
216 }
217 obs, usable := v.watch.Observed()
218 prev := v.snap.Load()
219 sn, failed, err := v.walk(ctx, prev)
220 if err != nil {
221 v.rebuildDue = true
222 err = fmt.Errorf("provision: record view: walk: %w", err)
223 if prev != nil {
224 failedSn := prev.clone()
225 failedSn.walkErr = err
226 v.publish(failedSn)
227 }
228 return err
229 }
230 if hook := recordViewRebuildHook; hook != nil {
231 hook()
232 }
233 v.applied = map[string]uint64{}
234 if usable {
235 for scope, gen := range obs {
236 if !viewScope(scope) || failed[scope] {
237 continue
238 }
239 if a, _, ok := ScopeApp(scope); ok && sn.dirErrs[a] != nil {
240 continue // its account's apps were never listed
241 }
242 v.applied[scope] = gen
243 }
244 }
245 v.pending = failed
246 v.rebuildDue = len(sn.dirErrs) > 0
247 // A walk whose ctx ended mid-way has its dead reads cached as failures:
248 // published incomplete, retried by the next Refresh.
249 v.publish(sn)
250 return ctx.Err()
251}
252
253// publish stamps sn with the next epoch and its completeness and swaps it in.
254func (v *RecordView) publish(sn *ViewSnapshot) {
255 sn.Complete = len(v.pending) == 0 && !v.rebuildDue && walkComplete(sn)
256 v.epoch++
257 sn.Epoch = v.epoch
258 v.snap.Store(sn)
259}
260
261// walkComplete runs the record walk itself over the snapshot, answering from
262// the cache alone, and reports whether it would be partial: an account
263// manifest that will not read cleanly, or an app listing that failed. Those
264// are exactly what a strict reader refuses on the bucket.
265func walkComplete(sn *ViewSnapshot) bool {
266 ctx := context.Background()
267 s := sn.Store(uncachedStore{})
268 known, readFailed, err := knownAccounts(ctx, s)
269 if err != nil || readFailed {
270 return false
271 }
272 for _, sub := range []string{"instances/", "sidecars/"} {
273 if _, partial, err := listAppSubtree(ctx, s, known, sub); err != nil || partial {
274 return false
275 }
276 }
277 return true
278}
279
280// scopeRead is one scope's re-read: the new entry and whether every read in
281// it succeeded.
282type scopeRead struct {
283 acct *acctEntry
284 app *appEntry
285 ok bool
286}
287
288func (v *RecordView) readScope(ctx context.Context, prev *ViewSnapshot, scope string) scopeRead {
289 if a, ok := scopeAccount(scope); ok {
290 e, ok := v.readAccount(ctx, a, prev.accounts[a])
291 return scopeRead{acct: e, ok: ok}
292 }
293 a, b, _ := ScopeApp(scope)
294 p := release.AppPrefix(a, b)
295 inst, instErr := listETags(ctx, v.live, p+"instances/")
296 side, sideErr := listETags(ctx, v.live, p+"sidecars/")
297 e, ok := v.readApp(ctx, a, b, prev.apps[a][b], listing{inst, instErr}, listing{side, sideErr})
298 return scopeRead{app: e, ok: ok}
299}
300
301// readAccount re-reads accounts/<a>/: its listing, and account.json only
302// when its ETag moved.
303func (v *RecordView) readAccount(ctx context.Context, a string, prev *acctEntry) (*acctEntry, bool) {
304 key := accountManifestKey(a)
305 kes, err := listETags(ctx, v.live, "accounts/"+a+"/")
306 if err != nil {
307 return &acctEntry{listErr: err, manifest: object{err: err}}, false
308 }
309 e := &acctEntry{manifest: absent(key)}
310 i := slices.IndexFunc(kes, func(k release.KeyETag) bool { return k.Key == key })
311 if i >= 0 {
312 var was *object
313 if prev != nil {
314 was = &prev.manifest
315 }
316 e.manifest = v.fetch(ctx, key, kes[i].ETag, was)
317 if errors.Is(e.manifest.err, os.ErrNotExist) {
318 kes = slices.Delete(kes, i, i+1) // deleted since it was listed
319 }
320 }
321 e.keys = kes
322 return e, !e.manifest.failed()
323}
324
325// listing is one prefix's ETag listing, or why it failed.
326type listing struct {
327 kes []release.KeyETag
328 err error
329}
330
331// readApp fills one app's entry from its two listings: every listed record
332// body (kept from prev when the ETag is unchanged), then both manifests and
333// the trash marker by conditional GET.
334func (v *RecordView) readApp(ctx context.Context, a, b string, prev *appEntry, inst, side listing) (*appEntry, bool) {
335 e := &appEntry{instErr: inst.err, sideErr: side.err, objs: map[string]object{}}
336 ok := inst.err == nil && side.err == nil
337 read := func(kes []release.KeyETag) []release.KeyETag {
338 var kept []release.KeyETag
339 for _, ke := range kes {
340 o := v.fetch(ctx, ke.Key, ke.ETag, prev.object(ke.Key))
341 if errors.Is(o.err, os.ErrNotExist) {
342 continue // deleted since it was listed: the listing follows the bucket
343 }
344 if o.failed() {
345 ok = false
346 }
347 e.objs[ke.Key] = o
348 kept = append(kept, ke)
349 }
350 return kept
351 }
352 e.inst, e.side = read(inst.kes), read(side.kes)
353 for _, key := range []string{ManifestKey(a, b), SidecarManifestKey(a, b), release.AppTombstoneKey(a, b)} {
354 o := condGet(ctx, v.live, key, prev.object(key))
355 if o.failed() {
356 ok = false
357 }
358 e.objs[key] = o
359 }
360 return e, ok
361}
362
363// fetch reads key unless prev already holds the body the listing's ETag
364// names.
365func (v *RecordView) fetch(ctx context.Context, key, listed string, prev *object) object {
366 if prev != nil && prev.err == nil && listed != "" && prev.etag == listed {
367 return *prev
368 }
369 return condGet(ctx, v.live, key, prev)
370}
371
372// condGet reads key with a conditional GET against prev's ETag, so an
373// unchanged object costs a 304. Any error from the extension other than
374// not-found means "unknown" and falls back to a plain Get, as does a store
375// without it.
376func condGet(ctx context.Context, s release.Store, key string, prev *object) object {
377 etag := ""
378 if prev != nil && prev.err == nil {
379 etag = prev.etag
380 }
381 if g, ok := s.(release.ConditionalGetter); ok {
382 body, newETag, changed, err := g.GetIfChanged(ctx, key, etag)
383 switch {
384 case err == nil && !changed && etag != "":
385 return *prev
386 case err == nil && changed:
387 return object{body: body, etag: newETag}
388 case errors.Is(err, os.ErrNotExist):
389 return object{err: err}
390 }
391 }
392 body, newETag, err := s.Get(ctx, key)
393 return object{body: body, etag: newETag, err: err}
394}
395
396// listETags lists prefix with each key's ETag, sorted. A store without
397// ETagLister, or any error from it, falls back to List with no ETags, which
398// only costs the body reads the ETags would have saved.
399func listETags(ctx context.Context, s release.Store, prefix string) ([]release.KeyETag, error) {
400 var kes []release.KeyETag
401 l, ok := s.(release.ETagLister)
402 var err error
403 if ok {
404 kes, err = l.ListETags(ctx, prefix)
405 }
406 if !ok || err != nil {
407 keys, err := s.List(ctx, prefix)
408 if err != nil {
409 return nil, err
410 }
411 kes = make([]release.KeyETag, len(keys))
412 for i, k := range keys {
413 kes[i] = release.KeyETag{Key: k}
414 }
415 }
416 slices.SortFunc(kes, func(x, y release.KeyETag) int { return strings.Compare(x.Key, y.Key) })
417 return kes, nil
418}
419
420// walk reads the whole deployment with the record walk's own code, knownAccounts
421// and listAppSubtree, over a store that keeps what they read. Apps are walked
422// under every account directory, not only known ones, so an account that
423// becomes known later (restored, or materialized after its apps) already has
424// its apps in the view. failed holds every scope with a read that failed.
425func (v *RecordView) walk(ctx context.Context, prev *ViewSnapshot) (*ViewSnapshot, map[string]bool, error) {
426 w := &walkStore{Store: v.live, prev: prev, lists: map[string]listing{}, dirs: map[string]dirListing{}, gets: map[string]object{}}
427 known, _, err := knownAccounts(ctx, w)
428 if err != nil {
429 return nil, nil, err
430 }
431 every := maps.Clone(known)
432 bucket := release.HasBucketListDirs(v.live)
433 if bucket {
434 dirs, err := w.ListDirs(ctx, "apps/")
435 if err != nil {
436 return nil, nil, fmt.Errorf("list apps: %w", err)
437 }
438 for _, d := range dirs {
439 every[strings.TrimSuffix(strings.TrimPrefix(d, "apps/"), "/")] = true
440 }
441 }
442 for _, sub := range []string{"instances/", "sidecars/"} {
443 if _, _, err := listAppSubtree(ctx, w, every, sub); err != nil {
444 return nil, nil, fmt.Errorf("list apps: %w", err)
445 }
446 }
447
448 sn := &ViewSnapshot{accounts: map[string]*acctEntry{}, apps: map[string]map[string]*appEntry{}, dirErrs: map[string]error{}}
449 failed := map[string]bool{}
450 for _, ke := range w.lists["accounts/"].kes {
451 scope, ok := release.ChangeScope(ke.Key)
452 a, isAcct := scopeAccount(scope)
453 if !ok || !isAcct {
454 sn.strays = append(sn.strays, ke.Key)
455 continue
456 }
457 e := sn.accounts[a]
458 if e == nil {
459 e = &acctEntry{manifest: absent(accountManifestKey(a))}
460 sn.accounts[a] = e
461 }
462 e.keys = append(e.keys, ke)
463 }
464 for a, e := range sn.accounts {
465 key := accountManifestKey(a)
466 if !slices.ContainsFunc(e.keys, func(k release.KeyETag) bool { return k.Key == key }) {
467 continue
468 }
469 o, ok := w.gets[key]
470 if !ok { // knownAccounts reads every manifest it lists; this is belt and braces
471 o = condGet(ctx, v.live, key, nil)
472 }
473 e.manifest = o
474 if o.failed() {
475 failed["accounts/"+a] = true
476 }
477 }
478
479 type appListings struct{ inst, side listing }
480 found := map[[2]string]*appListings{}
481 at := func(a, b string) *appListings {
482 k := [2]string{a, b}
483 if found[k] == nil {
484 found[k] = &appListings{}
485 }
486 return found[k]
487 }
488 if bucket {
489 for _, d := range w.dirs["apps/"].dirs {
490 a := strings.TrimSuffix(strings.TrimPrefix(d, "apps/"), "/")
491 ad, ok := w.dirs[d]
492 if !ok {
493 continue
494 }
495 if ad.err != nil {
496 sn.dirErrs[a] = ad.err
497 continue
498 }
499 for _, p := range ad.dirs {
500 b := strings.TrimSuffix(strings.TrimPrefix(p, d), "/")
501 l := at(a, b)
502 l.inst, l.side = w.lists[p+"instances/"], w.lists[p+"sidecars/"]
503 }
504 }
505 } else {
506 for _, ke := range w.lists["apps/"].kes {
507 parts := strings.SplitN(ke.Key, "/", 4)
508 if len(parts) < 4 || parts[1] == "" || parts[2] == "" {
509 continue
510 }
511 l := at(parts[1], parts[2])
512 switch {
513 case strings.HasPrefix(parts[3], "instances/"):
514 l.inst.kes = append(l.inst.kes, ke)
515 case strings.HasPrefix(parts[3], "sidecars/"):
516 l.side.kes = append(l.side.kes, ke)
517 }
518 }
519 }
520 ids := make([]string, 0, len(found))
521 for k := range found {
522 ids = append(ids, "apps/"+k[0]+"/"+k[1])
523 }
524 var mu sync.Mutex
525 forEach(ids, func(scope string) {
526 a, b, _ := ScopeApp(scope)
527 l := found[[2]string{a, b}]
528 e, ok := v.readApp(ctx, a, b, prev.app(a, b), l.inst, l.side)
529 mu.Lock()
530 defer mu.Unlock()
531 sn.setApp(a, b, e)
532 if !ok {
533 failed[scope] = true
534 }
535 })
536 return sn, failed, nil
537}
538
539// walkStore is the live store as the backstop walk sees it: every listing
540// carries ETags and every read is conditional on what the previous snapshot
541// held, and both are kept for the snapshot the walk builds. Each listing is
542// read once per walk, however many times the walk code asks.
543type walkStore struct {
544 release.Store
545 prev *ViewSnapshot
546
547 mu sync.Mutex
548 lists map[string]listing
549 dirs map[string]dirListing
550 gets map[string]object
551}
552
553type dirListing struct {
554 dirs []string
555 err error
556}
557
558func (w *walkStore) BucketShapedListDirs() bool { return release.HasBucketListDirs(w.Store) }
559
560func (w *walkStore) List(ctx context.Context, prefix string) ([]string, error) {
561 w.mu.Lock()
562 l, ok := w.lists[prefix]
563 w.mu.Unlock()
564 if !ok {
565 kes, err := listETags(ctx, w.Store, prefix)
566 l = listing{kes, err}
567 w.mu.Lock()
568 w.lists[prefix] = l
569 w.mu.Unlock()
570 }
571 return keysOf(l.kes), l.err
572}
573
574func (w *walkStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
575 w.mu.Lock()
576 d, ok := w.dirs[prefix]
577 w.mu.Unlock()
578 if !ok {
579 dirs, err := w.Store.ListDirs(ctx, prefix)
580 d = dirListing{dirs, err}
581 w.mu.Lock()
582 w.dirs[prefix] = d
583 w.mu.Unlock()
584 }
585 return slices.Clone(d.dirs), d.err
586}
587
588func (w *walkStore) Get(ctx context.Context, key string) ([]byte, string, error) {
589 var prev *object
590 if o, ok := w.prev.lookup(key); ok && o.err == nil {
591 prev = &o
592 }
593 o := condGet(ctx, w.Store, key, prev)
594 w.mu.Lock()
595 w.gets[key] = o
596 w.mu.Unlock()
597 return o.result()
598}
599
600func keysOf(kes []release.KeyETag) []string {
601 if kes == nil {
602 return nil
603 }
604 out := make([]string, len(kes))
605 for i, ke := range kes {
606 out[i] = ke.Key
607 }
608 return out
609}
610