package provision import ( "context" "errors" "fmt" "log" "maps" "os" "slices" "sort" "strings" "sync" "sync/atomic" "time" "github.com/carlosframework/platform/internal/release" ) // RecordView keeps the record families of the deployment bucket in memory // and follows the change index so a pass re-reads only the scopes that moved // (S3 change-index design §4). It holds, per account, the accounts// // listing and account.json; per app, the instances/ and sidecars/ listings // with every record body, the instances and sidecars manifests and the trash // marker. Consumers never read it directly: they take a Snapshot and run the // unchanged walk code (Reconcile, ReconcileSidecars, ListAllRecords*) over // Snapshot().Store(live). // // Two maps drive it (§4.2): the watch's observed generations and the view's // own applied ones. A scope whose applied generation is behind the observed // one, or that is new, or whose last re-read failed, is re-read on the next // Refresh, whether or not the index itself moved. Poll's moved list is never // used: several consumers share one watch, and whichever polls first takes it. type RecordView struct { live release.Store watch *release.ChangeWatch // pollTimeout bounds each index poll (release.ChangeIndexPollTimeout; // tests shorten it). pollTimeout time.Duration // mu serializes Refresh and Rebuild for their whole duration, the backstop // walk included, so a publication always reflects reads that began after // the previous publication's (§4.3). It is a leaf: nothing else is taken // while it is held (the watch's own locks are leaves below it). The fields // after it are guarded by it. mu sync.Mutex applied map[string]uint64 // scope -> the generation the view's contents reflect pending map[string]bool // scopes whose last read failed: re-read on every Refresh rebuildDue bool // the last walk failed or could not list an account's apps epoch uint64 snap atomic.Pointer[ViewSnapshot] } // recordViewRebuildHook, when set, runs inside Rebuild after the walk has // read everything and before it publishes, with mu held. Tests only. var recordViewRebuildHook func() // NewRecordView returns a view over live that follows watch. It has published // nothing: Snapshot is nil until the first Refresh or Rebuild succeeds. func NewRecordView(live release.Store, watch *release.ChangeWatch) *RecordView { return &RecordView{live: live, watch: watch, pollTimeout: release.ChangeIndexPollTimeout, applied: map[string]uint64{}, pending: map[string]bool{}} } // Snapshot is the latest publication, or nil before the first. It never // blocks on a refresh in progress. func (v *RecordView) Snapshot() *ViewSnapshot { return v.snap.Load() } // ScopeApp parses an app change scope, apps//. func ScopeApp(scope string) (acct, app string, ok bool) { rest, ok := strings.CutPrefix(scope, "apps/") if !ok { return "", "", false } acct, app, ok = strings.Cut(rest, "/") if !ok || acct == "" || app == "" || strings.Contains(app, "/") { return "", "", false } return acct, app, true } // scopeAccount parses an account change scope, accounts/. func scopeAccount(scope string) (string, bool) { a, ok := strings.CutPrefix(scope, "accounts/") if !ok || a == "" || strings.Contains(a, "/") { return "", false } return a, true } // viewScope reports whether the view keeps scope's contents. The control/ // scopes belong to other readers. func viewScope(scope string) bool { if _, _, ok := ScopeApp(scope); ok { return true } _, ok := scopeAccount(scope) return ok } // Refresh brings the view up to the change index (§4.2): one conditional GET // of the index, then a re-read of every scope whose applied generation is // behind the observed one, that is new, or whose last re-read failed. A view // that was never built, whose last walk was partial, or whose watch is // unusable (no index support, a denied or malformed index) runs Rebuild // instead, which is the walk every pass ran before the index (§3.4). // // A scope that fails to re-read keeps its applied generation, is retried on // the next Refresh even when the index answers 304, and is published with the // failure cached, so the snapshot is not Complete and a strict reader over it // fails as it would on the bucket. A refresh whose ctx ends mid-read still // publishes, with the reads that died cached as failures, and returns the // ctx error. A refresh that walks and fails publishes the walk's failure // (see Rebuild). Either way, no snapshot stays published as Complete once the // view knows it is behind: consumers decide on Complete, and a strict reader // over the snapshot fails where it would fail on the bucket. func (v *RecordView) Refresh(ctx context.Context) error { v.mu.Lock() defer v.mu.Unlock() prev := v.snap.Load() if prev == nil || v.rebuildDue { return v.rebuildLocked(ctx, true) } if err := v.poll(ctx); err != nil { log.Printf("provision: record view: change index unusable (%v): walking", err) return v.rebuildLocked(ctx, false) } obs, ok := v.watch.Observed() if !ok { return v.rebuildLocked(ctx, false) } var due []string for scope, gen := range obs { if have, ok := v.applied[scope]; viewScope(scope) && (!ok || have < gen) { due = append(due, scope) } } for scope := range v.pending { if !slices.Contains(due, scope) { due = append(due, scope) } } if len(due) == 0 { return nil } sort.Strings(due) var mu sync.Mutex results := make(map[string]scopeRead, len(due)) forEach(due, func(scope string) { r := v.readScope(ctx, prev, scope) mu.Lock() results[scope] = r mu.Unlock() }) next := prev.clone() for _, scope := range due { r := results[scope] if a, ok := scopeAccount(scope); ok { next.setAccount(a, r.acct) } else if a, b, ok := ScopeApp(scope); ok { next.setApp(a, b, r.app) } if r.ok { v.applied[scope] = obs[scope] delete(v.pending, scope) } else { log.Printf("provision: record view: re-reading %s failed: retrying on the next refresh", scope) v.pending[scope] = true } } // Reads that died with the caller are cached as failures and the scopes // stay pending: the published snapshot is incomplete, never stale-complete. v.publish(next) return ctx.Err() } // Rebuild is the backstop (§4.4): under mu it polls the index, takes the // observed generations, walks every account and app, publishes, and sets // applied to the generations read BEFORE the walk, so a bump that lands // during the walk is re-read by the next Refresh, never skipped. Unchanged // bodies already in the view are kept by ETag rather than read again. // // A walk whose top-level listing fails (accounts/, or apps/ on a bucket-shaped // store) returns the error and publishes the previous snapshot again with that // error cached: List("accounts/") and ListDirs("apps/") over it answer the // walk's failure, so knownAccounts, Reconcile and the strict readers fail over // it exactly as they fail on the bucket, and it is not Complete. The next // Refresh walks again. A view that never published stays unpublished. func (v *RecordView) Rebuild(ctx context.Context) error { v.mu.Lock() defer v.mu.Unlock() return v.rebuildLocked(ctx, true) } // poll is one index poll, bounded by pollTimeout inside ctx: it runs under // mu, and the callers' contexts are long-lived, so a stalled index GET must // not hold the view (and with it every reconcile pass) or the watch's shared // poll slot. A poll that runs out marks the watch unknown, and the caller // walks. func (v *RecordView) poll(ctx context.Context) error { pctx, cancel := context.WithTimeout(ctx, v.pollTimeout) defer cancel() _, err := v.watch.Poll(pctx) return err } // rebuildLocked is Rebuild under mu. poll is false when the caller's own // poll just failed: the watch is unknown, so a second poll could only spend // another bound before the same walk. func (v *RecordView) rebuildLocked(ctx context.Context, poll bool) error { if poll { if err := v.poll(ctx); err != nil { log.Printf("provision: record view: change index unusable (%v): the walk records no generations", err) } } obs, usable := v.watch.Observed() prev := v.snap.Load() sn, failed, err := v.walk(ctx, prev) if err != nil { v.rebuildDue = true err = fmt.Errorf("provision: record view: walk: %w", err) if prev != nil { failedSn := prev.clone() failedSn.walkErr = err v.publish(failedSn) } return err } if hook := recordViewRebuildHook; hook != nil { hook() } v.applied = map[string]uint64{} if usable { for scope, gen := range obs { if !viewScope(scope) || failed[scope] { continue } if a, _, ok := ScopeApp(scope); ok && sn.dirErrs[a] != nil { continue // its account's apps were never listed } v.applied[scope] = gen } } v.pending = failed v.rebuildDue = len(sn.dirErrs) > 0 // A walk whose ctx ended mid-way has its dead reads cached as failures: // published incomplete, retried by the next Refresh. v.publish(sn) return ctx.Err() } // publish stamps sn with the next epoch and its completeness and swaps it in. func (v *RecordView) publish(sn *ViewSnapshot) { sn.Complete = len(v.pending) == 0 && !v.rebuildDue && walkComplete(sn) v.epoch++ sn.Epoch = v.epoch v.snap.Store(sn) } // walkComplete runs the record walk itself over the snapshot, answering from // the cache alone, and reports whether it would be partial: an account // manifest that will not read cleanly, or an app listing that failed. Those // are exactly what a strict reader refuses on the bucket. func walkComplete(sn *ViewSnapshot) bool { ctx := context.Background() s := sn.Store(uncachedStore{}) known, readFailed, err := knownAccounts(ctx, s) if err != nil || readFailed { return false } for _, sub := range []string{"instances/", "sidecars/"} { if _, partial, err := listAppSubtree(ctx, s, known, sub); err != nil || partial { return false } } return true } // scopeRead is one scope's re-read: the new entry and whether every read in // it succeeded. type scopeRead struct { acct *acctEntry app *appEntry ok bool } func (v *RecordView) readScope(ctx context.Context, prev *ViewSnapshot, scope string) scopeRead { if a, ok := scopeAccount(scope); ok { e, ok := v.readAccount(ctx, a, prev.accounts[a]) return scopeRead{acct: e, ok: ok} } a, b, _ := ScopeApp(scope) p := release.AppPrefix(a, b) inst, instErr := listETags(ctx, v.live, p+"instances/") side, sideErr := listETags(ctx, v.live, p+"sidecars/") e, ok := v.readApp(ctx, a, b, prev.apps[a][b], listing{inst, instErr}, listing{side, sideErr}) return scopeRead{app: e, ok: ok} } // readAccount re-reads accounts//: its listing, and account.json only // when its ETag moved. func (v *RecordView) readAccount(ctx context.Context, a string, prev *acctEntry) (*acctEntry, bool) { key := accountManifestKey(a) kes, err := listETags(ctx, v.live, "accounts/"+a+"/") if err != nil { return &acctEntry{listErr: err, manifest: object{err: err}}, false } e := &acctEntry{manifest: absent(key)} i := slices.IndexFunc(kes, func(k release.KeyETag) bool { return k.Key == key }) if i >= 0 { var was *object if prev != nil { was = &prev.manifest } e.manifest = v.fetch(ctx, key, kes[i].ETag, was) if errors.Is(e.manifest.err, os.ErrNotExist) { kes = slices.Delete(kes, i, i+1) // deleted since it was listed } } e.keys = kes return e, !e.manifest.failed() } // listing is one prefix's ETag listing, or why it failed. type listing struct { kes []release.KeyETag err error } // readApp fills one app's entry from its two listings: every listed record // body (kept from prev when the ETag is unchanged), then both manifests and // the trash marker by conditional GET. func (v *RecordView) readApp(ctx context.Context, a, b string, prev *appEntry, inst, side listing) (*appEntry, bool) { e := &appEntry{instErr: inst.err, sideErr: side.err, objs: map[string]object{}} ok := inst.err == nil && side.err == nil read := func(kes []release.KeyETag) []release.KeyETag { var kept []release.KeyETag for _, ke := range kes { o := v.fetch(ctx, ke.Key, ke.ETag, prev.object(ke.Key)) if errors.Is(o.err, os.ErrNotExist) { continue // deleted since it was listed: the listing follows the bucket } if o.failed() { ok = false } e.objs[ke.Key] = o kept = append(kept, ke) } return kept } e.inst, e.side = read(inst.kes), read(side.kes) for _, key := range []string{ManifestKey(a, b), SidecarManifestKey(a, b), release.AppTombstoneKey(a, b)} { o := condGet(ctx, v.live, key, prev.object(key)) if o.failed() { ok = false } e.objs[key] = o } return e, ok } // fetch reads key unless prev already holds the body the listing's ETag // names. func (v *RecordView) fetch(ctx context.Context, key, listed string, prev *object) object { if prev != nil && prev.err == nil && listed != "" && prev.etag == listed { return *prev } return condGet(ctx, v.live, key, prev) } // condGet reads key with a conditional GET against prev's ETag, so an // unchanged object costs a 304. Any error from the extension other than // not-found means "unknown" and falls back to a plain Get, as does a store // without it. func condGet(ctx context.Context, s release.Store, key string, prev *object) object { etag := "" if prev != nil && prev.err == nil { etag = prev.etag } if g, ok := s.(release.ConditionalGetter); ok { body, newETag, changed, err := g.GetIfChanged(ctx, key, etag) switch { case err == nil && !changed && etag != "": return *prev case err == nil && changed: return object{body: body, etag: newETag} case errors.Is(err, os.ErrNotExist): return object{err: err} } } body, newETag, err := s.Get(ctx, key) return object{body: body, etag: newETag, err: err} } // listETags lists prefix with each key's ETag, sorted. A store without // ETagLister, or any error from it, falls back to List with no ETags, which // only costs the body reads the ETags would have saved. func listETags(ctx context.Context, s release.Store, prefix string) ([]release.KeyETag, error) { var kes []release.KeyETag l, ok := s.(release.ETagLister) var err error if ok { kes, err = l.ListETags(ctx, prefix) } if !ok || err != nil { keys, err := s.List(ctx, prefix) if err != nil { return nil, err } kes = make([]release.KeyETag, len(keys)) for i, k := range keys { kes[i] = release.KeyETag{Key: k} } } slices.SortFunc(kes, func(x, y release.KeyETag) int { return strings.Compare(x.Key, y.Key) }) return kes, nil } // walk reads the whole deployment with the record walk's own code, knownAccounts // and listAppSubtree, over a store that keeps what they read. Apps are walked // under every account directory, not only known ones, so an account that // becomes known later (restored, or materialized after its apps) already has // its apps in the view. failed holds every scope with a read that failed. func (v *RecordView) walk(ctx context.Context, prev *ViewSnapshot) (*ViewSnapshot, map[string]bool, error) { w := &walkStore{Store: v.live, prev: prev, lists: map[string]listing{}, dirs: map[string]dirListing{}, gets: map[string]object{}} known, _, err := knownAccounts(ctx, w) if err != nil { return nil, nil, err } every := maps.Clone(known) bucket := release.HasBucketListDirs(v.live) if bucket { dirs, err := w.ListDirs(ctx, "apps/") if err != nil { return nil, nil, fmt.Errorf("list apps: %w", err) } for _, d := range dirs { every[strings.TrimSuffix(strings.TrimPrefix(d, "apps/"), "/")] = true } } for _, sub := range []string{"instances/", "sidecars/"} { if _, _, err := listAppSubtree(ctx, w, every, sub); err != nil { return nil, nil, fmt.Errorf("list apps: %w", err) } } sn := &ViewSnapshot{accounts: map[string]*acctEntry{}, apps: map[string]map[string]*appEntry{}, dirErrs: map[string]error{}} failed := map[string]bool{} for _, ke := range w.lists["accounts/"].kes { scope, ok := release.ChangeScope(ke.Key) a, isAcct := scopeAccount(scope) if !ok || !isAcct { sn.strays = append(sn.strays, ke.Key) continue } e := sn.accounts[a] if e == nil { e = &acctEntry{manifest: absent(accountManifestKey(a))} sn.accounts[a] = e } e.keys = append(e.keys, ke) } for a, e := range sn.accounts { key := accountManifestKey(a) if !slices.ContainsFunc(e.keys, func(k release.KeyETag) bool { return k.Key == key }) { continue } o, ok := w.gets[key] if !ok { // knownAccounts reads every manifest it lists; this is belt and braces o = condGet(ctx, v.live, key, nil) } e.manifest = o if o.failed() { failed["accounts/"+a] = true } } type appListings struct{ inst, side listing } found := map[[2]string]*appListings{} at := func(a, b string) *appListings { k := [2]string{a, b} if found[k] == nil { found[k] = &appListings{} } return found[k] } if bucket { for _, d := range w.dirs["apps/"].dirs { a := strings.TrimSuffix(strings.TrimPrefix(d, "apps/"), "/") ad, ok := w.dirs[d] if !ok { continue } if ad.err != nil { sn.dirErrs[a] = ad.err continue } for _, p := range ad.dirs { b := strings.TrimSuffix(strings.TrimPrefix(p, d), "/") l := at(a, b) l.inst, l.side = w.lists[p+"instances/"], w.lists[p+"sidecars/"] } } } else { for _, ke := range w.lists["apps/"].kes { parts := strings.SplitN(ke.Key, "/", 4) if len(parts) < 4 || parts[1] == "" || parts[2] == "" { continue } l := at(parts[1], parts[2]) switch { case strings.HasPrefix(parts[3], "instances/"): l.inst.kes = append(l.inst.kes, ke) case strings.HasPrefix(parts[3], "sidecars/"): l.side.kes = append(l.side.kes, ke) } } } ids := make([]string, 0, len(found)) for k := range found { ids = append(ids, "apps/"+k[0]+"/"+k[1]) } var mu sync.Mutex forEach(ids, func(scope string) { a, b, _ := ScopeApp(scope) l := found[[2]string{a, b}] e, ok := v.readApp(ctx, a, b, prev.app(a, b), l.inst, l.side) mu.Lock() defer mu.Unlock() sn.setApp(a, b, e) if !ok { failed[scope] = true } }) return sn, failed, nil } // walkStore is the live store as the backstop walk sees it: every listing // carries ETags and every read is conditional on what the previous snapshot // held, and both are kept for the snapshot the walk builds. Each listing is // read once per walk, however many times the walk code asks. type walkStore struct { release.Store prev *ViewSnapshot mu sync.Mutex lists map[string]listing dirs map[string]dirListing gets map[string]object } type dirListing struct { dirs []string err error } func (w *walkStore) BucketShapedListDirs() bool { return release.HasBucketListDirs(w.Store) } func (w *walkStore) List(ctx context.Context, prefix string) ([]string, error) { w.mu.Lock() l, ok := w.lists[prefix] w.mu.Unlock() if !ok { kes, err := listETags(ctx, w.Store, prefix) l = listing{kes, err} w.mu.Lock() w.lists[prefix] = l w.mu.Unlock() } return keysOf(l.kes), l.err } func (w *walkStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { w.mu.Lock() d, ok := w.dirs[prefix] w.mu.Unlock() if !ok { dirs, err := w.Store.ListDirs(ctx, prefix) d = dirListing{dirs, err} w.mu.Lock() w.dirs[prefix] = d w.mu.Unlock() } return slices.Clone(d.dirs), d.err } func (w *walkStore) Get(ctx context.Context, key string) ([]byte, string, error) { var prev *object if o, ok := w.prev.lookup(key); ok && o.err == nil { prev = &o } o := condGet(ctx, w.Store, key, prev) w.mu.Lock() w.gets[key] = o w.mu.Unlock() return o.result() } func keysOf(kes []release.KeyETag) []string { if kes == nil { return nil } out := make([]string, len(kes)) for i, ke := range kes { out[i] = ke.Key } return out }