package console import ( "sync" "github.com/carlosframework/platform/internal/objstore" ) // storeWalkLocks serialises the object-storage walks per app, which is what // objstore has always said this package does. // // `deleteRecordIfUnchanged` narrows the grant-vs-teardown window and then // accepts what is left of it, for a stated reason (`walk.go:560`): // // This narrows the window; it does not close it. A grant landing between // the re-read and the Delete still loses its record, and only a CAS delete // in the Store interface could fix that. The console door serialises these // verbs per app, so the remaining window is a fraction of one round trip. // // Until this type there was no such serialisation anywhere in the console — // no lock of any kind around Grant, Rotate, RotateFinish or Teardown. The // premise the walks reason from was simply untrue, and the window was a whole // walk wide rather than a fraction of a round trip. // // It mattered less than it might have because reaching it took two operators // running store verbs against one app simultaneously. Self-serve object // storage (#381) removes that accident: a member's own `carlos store create` // drives a grant walk, and the create door's own comment says people re-run it // "from a second terminal, from a deploy script" (apistore.go:176-179). // // IN-PROCESS ONLY, and deliberately not sold as more. One console process // holds one of these, so two consoles fronting one deployment would still // interleave. That residue is what the walks' CAS guards are for — Grant's // step-six retry, Rotate's step-five retry, and this delete's re-read. The // lock removes the ordinary race; CAS survives the exotic one. // // Entries are reference-counted and dropped at zero, so an app that is walked // once does not hold a mutex for the life of the process. type storeWalkLocks struct { mu sync.Mutex m map[string]*storeWalkLock } type storeWalkLock struct { mu sync.Mutex refs int } func newStoreWalkLocks() *storeWalkLocks { return &storeWalkLocks{m: make(map[string]*storeWalkLock)} } // storeWalkKey is the map's key shape, in one place because tests inspect the // map to prove a second walk really queued. When the environment joined the // key on 2026-09-19 the literal was restated in two test files, and both went // on compiling while reading a refcount that could never move — they failed // as "the waiter never registered", which is a sentence about the lock rather // than about the key. Derive it here. // // The key is the store's IAM user name rather than the triple spelled out, // because two different triples can resolve to ONE estate: app "shop" with // environment "eu" and app "shop-eu" mint the same bucket and the same user // (#218). objstore's name claims refuse the second of them whichever lock is // held, but walks over one estate are what this lock exists to serialise, so // it keys on the estate's name. func storeWalkKey(acct, app, env string) string { return objstore.UserNameIn(acct, app, env) } // lock blocks until this store's walk lock is held, and returns the release. // The caller defers it: // // defer s.storeWalks.lock(acct.Sqid, app, env)() // // The key covers the account, the app AND the environment ("" for the app's // own store): two apps in one account share no estate, and since 2026-09-19 // two STORES of one app share none either — a bucket, an IAM user and a // config bundle each, all named for the triple (objstore's MintedBucketIn and // UserNameIn). Serialising across any of them would cost concurrency and buy // nothing. The one exception is triples whose joined names collide, which // storeWalkKey folds into one key. func (l *storeWalkLocks) lock(acct, app, env string) func() { key := storeWalkKey(acct, app, env) l.mu.Lock() e := l.m[key] if e == nil { e = &storeWalkLock{} l.m[key] = e } e.refs++ l.mu.Unlock() e.mu.Lock() return func() { e.mu.Unlock() l.mu.Lock() e.refs-- if e.refs == 0 { delete(l.m, key) } l.mu.Unlock() } }