package site import ( "context" "maps" "sync" "sync/atomic" "github.com/iceBear67/simplepages/internal/store" ) // ProjectConfig is the serving-time part of a project row, copied out so the // read path never touches the database. It is replaced wholesale, never edited // in place, so a reader always sees one consistent set of settings. type ProjectConfig struct { IndexFile string NotFoundFile string SPAFallback bool CacheControl string } // ConfigOf extracts what serving needs from a project row. func ConfigOf(p *store.Project) *ProjectConfig { return &ProjectConfig{ IndexFile: p.IndexFile, NotFoundFile: p.NotFoundFile, SPAFallback: p.SPAFallback, CacheControl: p.CacheControl, } } // Project is one project's live serving state. // // Both fields are atomic pointers to immutable values, so every read on the // serving path is one atomic load and nothing else. Writers are already // serialised per project by the deploy service's lock; the atomics are here for // the readers, not for mutual exclusion. type Project struct { ID int64 Name string cfg atomic.Pointer[ProjectConfig] active atomic.Pointer[Deployment] } // NewProject builds a registry entry with no deployment activated yet. func NewProject(p *store.Project) *Project { sp := &Project{ID: p.ID, Name: p.Name} sp.cfg.Store(ConfigOf(p)) return sp } // Active is the deployment this project is serving, or nil if it has never // activated one. // // A request handler calls this exactly once, at the top, and uses the value it // gets for the rest of the request. Calling it a second time within one request // is a bug: the two loads could straddle an activation and produce a response // assembled from two different versions, which is the exact failure this whole // program exists to prevent. func (p *Project) Active() *Deployment { return p.active.Load() } // Config is the project's serving settings. func (p *Project) Config() *ProjectConfig { return p.cfg.Load() } // Activate publishes a snapshot. This single store is the version switch. func (p *Project) Activate(d *Deployment) { p.active.Store(d) } // SetConfig swaps in new serving settings. func (p *Project) SetConfig(c *ProjectConfig) { p.cfg.Store(c) } // Registry maps project names to their live state. // // Reads are overwhelmingly more common than writes — every static request does // one, while the set of projects changes on the order of minutes to days — so // the map is copy-on-write behind an atomic pointer: readers get a lock-free, // allocation-free lookup against a consistent snapshot of the whole set, and // writers pay O(n) to clone it. A RWMutex would put one shared cache line in // every request's path for no benefit at this write rate. // // Note the layering: activating a deployment does *not* rebuild this map. It // stores into the Project the map already points at, so the copy is reserved for // changes to the project set itself. type Registry struct { mu sync.Mutex // serialises writers only; readers never take it byName atomic.Pointer[map[string]*Project] } func NewRegistry() *Registry { r := &Registry{} r.byName.Store(&map[string]*Project{}) return r } // Lookup finds a project by the name in the URL. func (r *Registry) Lookup(name string) (*Project, bool) { p, ok := (*r.byName.Load())[name] return p, ok } // Len is the number of projects the registry knows about. func (r *Registry) Len() int { return len(*r.byName.Load()) } // Projects returns the current entries in no particular order. func (r *Registry) Projects() []*Project { m := *r.byName.Load() out := make([]*Project, 0, len(m)) for _, p := range m { out = append(out, p) } return out } // ResolveProject satisfies auth.ProjectResolver, which lets the ownership guard // on the hot deployment endpoints answer from memory instead of querying. // // It is only safe in that role because the registry is fully built from the // database before the listeners start: a partially populated registry would // report someone's own project as unknown. func (r *Registry) ResolveProject(_ context.Context, name string) (int64, error) { p, ok := r.Lookup(name) if !ok { return 0, store.ErrNotFound } return p.ID, nil } // Replace swaps in a whole new project set, which is how startup publishes the // registry it built from the database. func (r *Registry) Replace(ps []*Project) { m := make(map[string]*Project, len(ps)) for _, p := range ps { m[p.Name] = p } r.mu.Lock() defer r.mu.Unlock() r.byName.Store(&m) } // Put adds or updates a project and returns its live entry. // // An entry that is already present is kept rather than rebuilt, so a settings // change does not disturb the deployment the project is currently serving. func (r *Registry) Put(p *store.Project) *Project { r.mu.Lock() defer r.mu.Unlock() old := *r.byName.Load() if sp, ok := old[p.Name]; ok && sp.ID == p.ID { sp.SetConfig(ConfigOf(p)) return sp } sp := NewProject(p) next := maps.Clone(old) if next == nil { next = make(map[string]*Project, 1) } next[p.Name] = sp r.byName.Store(&next) return sp } // Delete drops a project. Requests for it start 404ing as soon as the new map // is stored; requests already reading a snapshot of it finish normally. func (r *Registry) Delete(name string) { r.mu.Lock() defer r.mu.Unlock() old := *r.byName.Load() if _, ok := old[name]; !ok { return } next := maps.Clone(old) delete(next, name) r.byName.Store(&next) }